diff --git a/.env.example b/.env.example index 848938f..e406c04 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key DATABASE_URL=postgres://user:password@host:5432/dbname DIRECT_URL=postgres://user:password@host:5432/dbname +MHT_CET_ADMIN_EMAILS=admin@example.com # Compatibility and Migration Workflows # The application runtime uses Supabase, but several legacy ingestion scripts diff --git a/app/api/mht-cet/admin/imports/route.ts b/app/api/mht-cet/admin/imports/route.ts new file mode 100644 index 0000000..db934f2 --- /dev/null +++ b/app/api/mht-cet/admin/imports/route.ts @@ -0,0 +1,233 @@ +import { createAdminClient } from "@/app/lib/supabase/admin"; +import { MhtCetAdminError, requireMhtCetAdmin } from "@/lib/mht-cet/admin/auth"; +import { validateQuestionImportRows } from "@/lib/mht-cet/questions/validate-question-import"; +import { NextResponse } from "next/server"; + +function blocksToText(blocks: unknown) { + return JSON.stringify(blocks) + .replace(/[{}\[\]":,]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function jsonError(error: MhtCetAdminError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function POST(request: Request) { + try { + const user = await requireMhtCetAdmin(); + const payload = (await request.json()) as + | { questions?: unknown[]; fileName?: string } + | unknown[]; + const rows = Array.isArray(payload) ? payload : payload.questions; + + if (!Array.isArray(rows)) { + throw new MhtCetAdminError( + 422, + "invalid_payload", + "Provide an array or questions array.", + ); + } + + const validation = validateQuestionImportRows(rows, { mode: "production" }); + const errorCount = validation.errors.filter( + (error) => error.severity === "error", + ).length; + + if (errorCount > 0) { + return NextResponse.json( + { + acceptedRows: 0, + rejectedRows: rows.length, + errors: validation.errors, + }, + { status: 422 }, + ); + } + + const supabase = createAdminClient(); + let firstSourceId: string | null = null; + + for (const row of validation.validRows) { + const sourcePayload = { + source_type: row.source.sourceType, + title: row.source.title, + year: row.source.year ?? row.year, + exam_group: row.source.examGroup ?? row.examGroup ?? "pcm", + source_url: row.source.sourceUrl, + file_name: row.source.fileName, + file_sha256: row.source.fileSha256, + license_note: row.source.licenseNote, + verification_status: "validated", + }; + const { data: source, error: sourceError } = row.source.fileSha256 + ? await supabase + .from("mht_cet_question_sources") + .upsert(sourcePayload, { onConflict: "file_sha256" }) + .select("id") + .single() + : await supabase + .from("mht_cet_question_sources") + .insert(sourcePayload) + .select("id") + .single(); + + if (sourceError || !source) { + throw new MhtCetAdminError( + 500, + "source_insert_failed", + "Could not insert question source.", + ); + } + + firstSourceId ??= (source as { id: string }).id; + + const { data: chapter } = await supabase + .from("mht_cet_chapters") + .select("id") + .eq("subject", row.subject) + .eq("slug", row.chapterSlug ?? "") + .maybeSingle(); + const { data: question, error: questionError } = await supabase + .from("mht_cet_questions") + .insert({ + source_id: (source as { id: string }).id, + chapter_id: (chapter as { id?: string } | null)?.id, + year: row.year, + exam_group: row.examGroup ?? "pcm", + subject: row.subject, + difficulty: row.difficulty ?? "unknown", + question_type: row.questionType, + marks: row.marks ?? (row.subject === "mathematics" ? 2 : 1), + negative_marks: row.negativeMarks ?? 0, + body: row.body, + body_text: blocksToText(row.body), + body_sha256: row.bodySha256, + verification_status: "validated", + }) + .select("id") + .single(); + + if (questionError || !question) { + throw new MhtCetAdminError( + 500, + "question_insert_failed", + "Could not insert question.", + ); + } + + const questionId = (question as { id: string }).id; + const { data: options, error: optionsError } = await supabase + .from("mht_cet_question_options") + .insert( + row.options.map((option, optionIndex) => ({ + question_id: questionId, + option_order: optionIndex + 1, + body: option.body, + body_text: blocksToText(option.body), + })), + ) + .select("id, option_order"); + + if (optionsError || !options) { + throw new MhtCetAdminError( + 500, + "option_insert_failed", + "Could not insert options.", + ); + } + + const insertedOptions = options as Array<{ + id: string; + option_order: number; + }>; + const optionByImportId = new Map(); + + for (const [optionIndex, option] of row.options.entries()) { + const insertedOption = insertedOptions.find( + (item) => item.option_order === optionIndex + 1, + ); + + if (!insertedOption) { + throw new MhtCetAdminError( + 500, + "option_mapping_failed", + "Could not map imported options to saved options.", + ); + } + + optionByImportId.set(option.id, insertedOption.id); + } + + const correctOptionIds = row.correctOptionIds + .map((optionId) => optionByImportId.get(optionId)) + .filter((optionId): optionId is string => Boolean(optionId)); + + if (correctOptionIds.length !== row.correctOptionIds.length) { + throw new MhtCetAdminError( + 500, + "answer_mapping_failed", + "Could not map correct answers to saved options.", + ); + } + + const { error: answerError } = await supabase + .from("mht_cet_question_answers") + .insert({ + question_id: questionId, + correct_option_ids: correctOptionIds, + explanation: row.explanation, + explanation_text: row.explanation + ? blocksToText(row.explanation) + : null, + }); + + if (answerError) { + throw new MhtCetAdminError( + 500, + "answer_insert_failed", + "Could not insert answer key.", + ); + } + } + + if (validation.validRows[0] && firstSourceId) { + const firstRow = validation.validRows[0]; + await supabase.from("mht_cet_question_import_batches").insert({ + source_id: firstSourceId, + imported_by: user.id, + file_name: Array.isArray(payload) + ? "json-upload" + : (payload.fileName ?? "json-upload"), + file_sha256: firstRow.source.fileSha256 ?? firstRow.bodySha256, + total_rows: rows.length, + accepted_rows: validation.validRows.length, + rejected_rows: 0, + status: "imported", + }); + } + + return NextResponse.json({ + acceptedRows: validation.validRows.length, + rejectedRows: 0, + }); + } catch (error) { + if (error instanceof MhtCetAdminError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { + code: "import_failed", + message: "Could not import questions.", + }, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/mht-cet/admin/questions/[questionId]/review/route.ts b/app/api/mht-cet/admin/questions/[questionId]/review/route.ts new file mode 100644 index 0000000..7d5ffa7 --- /dev/null +++ b/app/api/mht-cet/admin/questions/[questionId]/review/route.ts @@ -0,0 +1,104 @@ +import { createAdminClient } from "@/app/lib/supabase/admin"; +import { MhtCetAdminError, requireMhtCetAdmin } from "@/lib/mht-cet/admin/auth"; +import { NextResponse } from "next/server"; + +function jsonError(error: MhtCetAdminError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ questionId: string }> }, +) { + try { + const user = await requireMhtCetAdmin(); + const { questionId } = await params; + const body = (await request.json()) as { action?: string }; + const nextStatus = body.action === "approved" ? "approved" : "rejected"; + const supabase = createAdminClient(); + const { data: question, error: loadError } = await supabase + .from("mht_cet_questions") + .select("source_id, source:mht_cet_question_sources(verification_status)") + .eq("id", questionId) + .single(); + + if (loadError || !question) { + throw new MhtCetAdminError( + 404, + "question_not_found", + "Question not found.", + ); + } + + const questionRecord = question as { + source_id?: string; + source?: { verification_status?: string }; + }; + const source = questionRecord.source; + + if (body.action === "approve_source") { + const { error } = await supabase + .from("mht_cet_question_sources") + .update({ + verification_status: "approved", + reviewed_by: user.id, + reviewed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", questionRecord.source_id); + + if (error) { + throw new MhtCetAdminError( + 500, + "source_review_failed", + "Could not approve source.", + ); + } + + return NextResponse.json({ status: "source_approved" }); + } + + if ( + nextStatus === "approved" && + source?.verification_status !== "approved" + ) { + throw new MhtCetAdminError( + 409, + "source_not_approved", + "Approve the source before approving questions.", + ); + } + + const { error } = await supabase + .from("mht_cet_questions") + .update({ + verification_status: nextStatus, + updated_at: new Date().toISOString(), + }) + .eq("id", questionId); + + if (error) { + throw new MhtCetAdminError( + 500, + "review_failed", + "Could not update review status.", + ); + } + + return NextResponse.json({ status: nextStatus }); + } catch (error) { + if (error instanceof MhtCetAdminError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { code: "review_failed", message: "Could not review question." }, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/mht-cet/mock-tests/attempts/[attemptId]/responses/route.ts b/app/api/mht-cet/mock-tests/attempts/[attemptId]/responses/route.ts new file mode 100644 index 0000000..c3aae9a --- /dev/null +++ b/app/api/mht-cet/mock-tests/attempts/[attemptId]/responses/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; + +import { + getCurrentUserOrUnauthorized, + MhtCetMockTestError, + parseAttemptResponseInput, + upsertAttemptResponse, +} from "@/lib/mht-cet/mock-tests/supabase"; +import { checkMockTestRateLimit } from "@/lib/mht-cet/mock-tests/rate-limit"; + +function jsonError(error: MhtCetMockTestError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ attemptId: string }> }, +) { + try { + const { attemptId } = await params; + const { user } = await getCurrentUserOrUnauthorized(); + const rateLimit = checkMockTestRateLimit({ + key: `response-save:${user.id}:${attemptId}`, + limit: 240, + windowMs: 5 * 60 * 1000, + }); + + if (!rateLimit.ok) { + throw new MhtCetMockTestError( + 429, + "rate_limited", + `Try again in ${rateLimit.retryAfterSeconds} seconds.`, + ); + } + + const response = parseAttemptResponseInput(await request.json()); + const result = await upsertAttemptResponse(attemptId, user.id, response); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof MhtCetMockTestError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { + code: "response_save_failed", + message: "Could not save response.", + }, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/mht-cet/mock-tests/attempts/[attemptId]/results/route.ts b/app/api/mht-cet/mock-tests/attempts/[attemptId]/results/route.ts new file mode 100644 index 0000000..e2a5db2 --- /dev/null +++ b/app/api/mht-cet/mock-tests/attempts/[attemptId]/results/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { + getCurrentUserOrUnauthorized, + loadAttemptResults, + MhtCetMockTestError, +} from "@/lib/mht-cet/mock-tests/supabase"; + +function jsonError(error: MhtCetMockTestError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ attemptId: string }> }, +) { + try { + const { attemptId } = await params; + const { user } = await getCurrentUserOrUnauthorized(); + const results = await loadAttemptResults(attemptId, user.id); + + return NextResponse.json(results); + } catch (error) { + if (error instanceof MhtCetMockTestError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { + code: "results_load_failed", + message: "Could not load results.", + }, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/mht-cet/mock-tests/attempts/[attemptId]/route.ts b/app/api/mht-cet/mock-tests/attempts/[attemptId]/route.ts new file mode 100644 index 0000000..7196526 --- /dev/null +++ b/app/api/mht-cet/mock-tests/attempts/[attemptId]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { + getCurrentUserOrUnauthorized, + loadAttemptForUser, + MhtCetMockTestError, +} from "@/lib/mht-cet/mock-tests/supabase"; + +function jsonError(error: MhtCetMockTestError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ attemptId: string }> }, +) { + try { + const { attemptId } = await params; + const { user } = await getCurrentUserOrUnauthorized(); + const attempt = await loadAttemptForUser(attemptId, user.id); + + return NextResponse.json(attempt); + } catch (error) { + if (error instanceof MhtCetMockTestError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { + code: "attempt_load_failed", + message: "Could not load attempt.", + }, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/mht-cet/mock-tests/attempts/[attemptId]/submit/route.ts b/app/api/mht-cet/mock-tests/attempts/[attemptId]/submit/route.ts new file mode 100644 index 0000000..664b84f --- /dev/null +++ b/app/api/mht-cet/mock-tests/attempts/[attemptId]/submit/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; + +import { + getCurrentUserOrUnauthorized, + MhtCetMockTestError, + submitAttempt, +} from "@/lib/mht-cet/mock-tests/supabase"; +import { checkMockTestRateLimit } from "@/lib/mht-cet/mock-tests/rate-limit"; + +function jsonError(error: MhtCetMockTestError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function POST( + _request: Request, + { params }: { params: Promise<{ attemptId: string }> }, +) { + try { + const { attemptId } = await params; + const { user } = await getCurrentUserOrUnauthorized(); + const rateLimit = checkMockTestRateLimit({ + key: `attempt-submit:${user.id}:${attemptId}`, + limit: 20, + windowMs: 60 * 60 * 1000, + }); + + if (!rateLimit.ok) { + throw new MhtCetMockTestError( + 429, + "rate_limited", + `Try again in ${rateLimit.retryAfterSeconds} seconds.`, + ); + } + + const result = await submitAttempt(attemptId, user.id); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof MhtCetMockTestError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { + code: "attempt_submit_failed", + message: "Could not submit attempt.", + }, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/mht-cet/mock-tests/route.ts b/app/api/mht-cet/mock-tests/route.ts new file mode 100644 index 0000000..4d17165 --- /dev/null +++ b/app/api/mht-cet/mock-tests/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; + +import { + createAttemptWithQuestions, + getCurrentUserOrUnauthorized, + loadApprovedQuestionPool, + MhtCetMockTestError, + normalizeUnknownMockConfig, +} from "@/lib/mht-cet/mock-tests/supabase"; +import { checkMockTestRateLimit } from "@/lib/mht-cet/mock-tests/rate-limit"; +import { selectQuestionsForMock } from "@/lib/mht-cet/mock-tests/select-questions"; + +function jsonError(error: MhtCetMockTestError) { + return NextResponse.json( + { error: { code: error.code, message: error.message } }, + { status: error.status }, + ); +} + +export async function POST(request: Request) { + try { + const { user } = await getCurrentUserOrUnauthorized(); + const rateLimit = checkMockTestRateLimit({ + key: `mock-create:${user.id}`, + limit: 10, + windowMs: 60 * 60 * 1000, + }); + + if (!rateLimit.ok) { + throw new MhtCetMockTestError( + 429, + "rate_limited", + `Try again in ${rateLimit.retryAfterSeconds} seconds.`, + ); + } + + const config = normalizeUnknownMockConfig(await request.json()); + const seed = crypto.randomUUID(); + const pool = await loadApprovedQuestionPool(config); + const selection = selectQuestionsForMock({ pool, config, seed }); + + if (!selection.ok) { + return NextResponse.json( + { + error: { + code: selection.reason, + message: + "Not enough approved questions for this mock configuration.", + available: selection.available, + required: selection.required, + }, + }, + { status: 409 }, + ); + } + + const attemptId = await createAttemptWithQuestions( + user.id, + config, + selection.questions, + seed, + ); + + return NextResponse.json({ attemptId }, { status: 201 }); + } catch (error) { + if (error instanceof MhtCetMockTestError) { + return jsonError(error); + } + + return NextResponse.json( + { + error: { + code: "mock_create_failed", + message: "Could not create mock test.", + }, + }, + { status: 500 }, + ); + } +} diff --git a/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx b/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx index 0aaf9a5..53ec177 100644 --- a/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx +++ b/app/engineering/colleges/bits/cutoffs/components/authbutton.tsx @@ -22,12 +22,19 @@ export default async function AuthButton() {
- - - - {(user.name || "").charAt(0).toUpperCase()} - - +
diff --git a/app/layout.tsx b/app/layout.tsx index 0d5a4c1..4ae2d3d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import "katex/dist/katex.min.css"; import "./globals.css"; +import "@/components/mht-cet/questions/question-content.css"; import Navbar from "@/components/Navbar"; -import DiscordHeader from "@/components/DiscordHeader"; import Footer from "@/components/footer"; import { GoogleAnalytics } from "@next/third-parties/google"; import GrainEffect from "@/components/graineffect"; @@ -106,9 +107,7 @@ export default function RootLayout({ className={`${inter.className} relative min-h-screen overflow-x-hidden`} > - - - + }> ; + } + + throw error; + } + + const supabase = createAdminClient(); + const { data } = await supabase + .from("mht_cet_question_import_batches") + .select( + "id, file_name, status, total_rows, accepted_rows, rejected_rows, created_at", + ) + .order("created_at", { ascending: false }) + .limit(50); + + return ( +
+

MHT-CET Imports

+ [0]["batches"] + } + /> +
+ ); +} diff --git a/app/mht-cet/admin/questions/page.tsx b/app/mht-cet/admin/questions/page.tsx new file mode 100644 index 0000000..4a79aa6 --- /dev/null +++ b/app/mht-cet/admin/questions/page.tsx @@ -0,0 +1,68 @@ +import { createAdminClient } from "@/app/lib/supabase/admin"; +import { AdminAccessDenied } from "@/components/mht-cet/admin/AdminAccessDenied"; +import { QuestionContent } from "@/components/mht-cet/questions/QuestionContent"; +import { QuestionReviewPanel } from "@/components/mht-cet/admin/QuestionReviewPanel"; +import { MhtCetAdminError, requireMhtCetAdmin } from "@/lib/mht-cet/admin/auth"; +import type { QuestionBlock } from "@/lib/mht-cet/questions/content-schema"; + +type ReviewQuestion = { + id: string; + subject: string; + body: QuestionBlock[]; + verification_status: string; + source?: { title?: string; verification_status?: string } | null; +}; + +export default async function MhtCetAdminQuestionsPage() { + try { + await requireMhtCetAdmin(); + } catch (error) { + if (error instanceof MhtCetAdminError) { + return ; + } + + throw error; + } + + const supabase = createAdminClient(); + const { data } = await supabase + .from("mht_cet_questions") + .select( + "id, subject, body, verification_status, source:mht_cet_question_sources(title, verification_status)", + ) + .in("verification_status", ["draft", "validated"]) + .order("created_at", { ascending: false }) + .limit(50); + const questions = (data ?? []) as ReviewQuestion[]; + + return ( +
+

Question Review

+
+ {questions.map((question) => ( +
+
+
+

{question.subject}

+

+ {question.source?.title ?? "Unknown source"} -{" "} + {question.verification_status} +

+
+ +
+ +
+ ))} +
+
+ ); +} diff --git a/app/mht-cet/all-state-cutoffs/components/authbutton.tsx b/app/mht-cet/all-state-cutoffs/components/authbutton.tsx index 0aaf9a5..53ec177 100644 --- a/app/mht-cet/all-state-cutoffs/components/authbutton.tsx +++ b/app/mht-cet/all-state-cutoffs/components/authbutton.tsx @@ -22,12 +22,19 @@ export default async function AuthButton() {
- - - - {(user.name || "").charAt(0).toUpperCase()} - - +
diff --git a/app/mht-cet/mock-tests/attempts/[attemptId]/loading.tsx b/app/mht-cet/mock-tests/attempts/[attemptId]/loading.tsx new file mode 100644 index 0000000..8a350cd --- /dev/null +++ b/app/mht-cet/mock-tests/attempts/[attemptId]/loading.tsx @@ -0,0 +1,41 @@ +function SkeletonBlock({ className = "" }: { className?: string }) { + return ( +
+ ); +} + +export default function MhtCetAttemptLoading() { + return ( +
+
+
+ + +
+ +
+
+ +
+ + +
+ + + + +
+
+
+
+ ); +} diff --git a/app/mht-cet/mock-tests/attempts/[attemptId]/page.tsx b/app/mht-cet/mock-tests/attempts/[attemptId]/page.tsx new file mode 100644 index 0000000..e586729 --- /dev/null +++ b/app/mht-cet/mock-tests/attempts/[attemptId]/page.tsx @@ -0,0 +1,11 @@ +import { AttemptShell } from "@/components/mht-cet/mock-tests/AttemptShell"; + +export default async function MhtCetAttemptPage({ + params, +}: { + params: Promise<{ attemptId: string }>; +}) { + const { attemptId } = await params; + + return ; +} diff --git a/app/mht-cet/mock-tests/attempts/[attemptId]/results/loading.tsx b/app/mht-cet/mock-tests/attempts/[attemptId]/results/loading.tsx new file mode 100644 index 0000000..097ea61 --- /dev/null +++ b/app/mht-cet/mock-tests/attempts/[attemptId]/results/loading.tsx @@ -0,0 +1,29 @@ +function SkeletonBlock({ className = "" }: { className?: string }) { + return ( +
+ ); +} + +export default function MhtCetResultsLoading() { + return ( +
+
+ + +
+
+ + + + +
+
+ + + +
+
+ ); +} diff --git a/app/mht-cet/mock-tests/attempts/[attemptId]/results/page.tsx b/app/mht-cet/mock-tests/attempts/[attemptId]/results/page.tsx new file mode 100644 index 0000000..9e872e0 --- /dev/null +++ b/app/mht-cet/mock-tests/attempts/[attemptId]/results/page.tsx @@ -0,0 +1,11 @@ +import { ResultsSummary } from "@/components/mht-cet/mock-tests/ResultsSummary"; + +export default async function MhtCetAttemptResultsPage({ + params, +}: { + params: Promise<{ attemptId: string }>; +}) { + const { attemptId } = await params; + + return ; +} diff --git a/app/mht-cet/mock-tests/loading.tsx b/app/mht-cet/mock-tests/loading.tsx new file mode 100644 index 0000000..32b9292 --- /dev/null +++ b/app/mht-cet/mock-tests/loading.tsx @@ -0,0 +1,43 @@ +function SkeletonBlock({ className = "" }: { className?: string }) { + return ( +
+ ); +} + +export default function MhtCetMockTestsLoading() { + return ( +
+
+ + +
+
+
+ + +
+ + + +
+
+
+ + + + +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/app/mht-cet/mock-tests/new/loading.tsx b/app/mht-cet/mock-tests/new/loading.tsx new file mode 100644 index 0000000..31f9339 --- /dev/null +++ b/app/mht-cet/mock-tests/new/loading.tsx @@ -0,0 +1,33 @@ +function SkeletonBlock({ className = "" }: { className?: string }) { + return ( +
+ ); +} + +export default function NewMhtCetMockLoading() { + return ( +
+
+ + +
+
+
+ + + + +
+ +
+ + + +
+ +
+
+ ); +} diff --git a/app/mht-cet/mock-tests/new/page.tsx b/app/mht-cet/mock-tests/new/page.tsx new file mode 100644 index 0000000..f718519 --- /dev/null +++ b/app/mht-cet/mock-tests/new/page.tsx @@ -0,0 +1,23 @@ +import { MockBuilder } from "@/components/mht-cet/mock-tests/MockBuilder"; +import { + EMPTY_MOCK_TEST_AVAILABILITY, + loadMockTestAvailability, +} from "@/lib/mht-cet/mock-tests/supabase"; + +export default async function NewMhtCetMockPage() { + const availability = await loadMockTestAvailability().catch( + () => EMPTY_MOCK_TEST_AVAILABILITY, + ); + + return ( +
+
+

Create Mock

+

+ Choose the exact mix for this attempt. +

+
+ +
+ ); +} diff --git a/app/mht-cet/mock-tests/page.tsx b/app/mht-cet/mock-tests/page.tsx new file mode 100644 index 0000000..e13a705 --- /dev/null +++ b/app/mht-cet/mock-tests/page.tsx @@ -0,0 +1,239 @@ +import { Button } from "@/components/ui/button"; +import { getCurrentUser } from "@/lib/auth"; +import { + EMPTY_MOCK_TEST_AVAILABILITY, + loadMockTestAvailability, + loadRecentMockAttempts, + type MockAttemptSummary, +} from "@/lib/mht-cet/mock-tests/supabase"; +import { + BarChart3, + CheckCircle2, + Clock3, + FileText, + PlayCircle, + Trophy, +} from "lucide-react"; +import Link from "next/link"; + +const statusLabels: Record = { + in_progress: "In progress", + submitted: "Submitted", + expired: "Expired", + abandoned: "Abandoned", +}; + +function formatScore(attempt: MockAttemptSummary) { + if (attempt.displayStatus === "in_progress" && attempt.scoreRaw === 0) { + return "Pending"; + } + + return `${attempt.scoreRaw}/${attempt.maxScore}`; +} + +function formatDate(value: string) { + if (!value) { + return "Not recorded"; + } + + return new Intl.DateTimeFormat("en-IN", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function getAttemptHref(attempt: MockAttemptSummary) { + if (attempt.displayStatus === "in_progress") { + return `/mht-cet/mock-tests/attempts/${attempt.id}`; + } + + return `/mht-cet/mock-tests/attempts/${attempt.id}/results`; +} + +export default async function MhtCetMockTestsPage() { + const user = await getCurrentUser(); + const [availability, attempts] = await Promise.all([ + loadMockTestAvailability().catch(() => EMPTY_MOCK_TEST_AVAILABILITY), + user + ? loadRecentMockAttempts(user.id).catch(() => []) + : Promise.resolve([]), + ]); + const completedAttempts = attempts.filter( + (attempt) => attempt.displayStatus !== "in_progress", + ); + const bestAttempt = completedAttempts.reduce( + (best, attempt) => { + if (!best) { + return attempt; + } + + const bestRatio = best.scoreRaw / Math.max(best.maxScore, 1); + const attemptRatio = attempt.scoreRaw / Math.max(attempt.maxScore, 1); + + return attemptRatio > bestRatio ? attempt : best; + }, + null, + ); + + return ( +
+
+

+ MHT-CET Mock Tests +

+

+ Timed mocks with approved question imports, server-side scoring, and + clear result tables. +

+
+ +
+
+
+
+
+
+

+ Choose subjects, year, chapters, question count, and duration. +

+
+ +
+
+
+

+ {availability.totalApprovedQuestions} +

+

approved questions

+
+
+

+ {availability.yearCounts["2026"] ?? 0} +

+

2026 practice rows

+
+
+

+ {Math.min( + availability.subjectCounts.mathematics, + availability.subjectCounts.physics, + availability.subjectCounts.chemistry, + )} +

+

per PCM subject

+
+
+
+ +
+
+
+
+
+ Attempts + {attempts.length} +
+
+ Completed + + {completedAttempts.length} + +
+
+ Best score + + {bestAttempt ? formatScore(bestAttempt) : "Pending"} + +
+
+
+
+ +
+
+
+
+ {user ? null : ( + + )} +
+ + {!user ? ( +
+

+ Sign in before starting a mock to save attempts, review marks, and + reopen result tables later. +

+
+ ) : attempts.length === 0 ? ( +
+
+ ) : ( +
+
+ {attempts.map((attempt) => ( +
+
+
+ {attempt.displayStatus === "in_progress" ? ( +
+
+ {attempt.questionCount} questions + Score {formatScore(attempt)} + {attempt.correctCount} correct + + {formatDate(attempt.submittedAt ?? attempt.startedAt)} + +
+
+ +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/app/mht-cet/page.tsx b/app/mht-cet/page.tsx index 7fa05eb..e8b1ad3 100644 --- a/app/mht-cet/page.tsx +++ b/app/mht-cet/page.tsx @@ -40,6 +40,13 @@ const LINKS: { [key: string]: Link } = { src: "https://res.cloudinary.com/dfyrk32ua/image/upload/v1721510815/deetnuts/logos/MHT-CET_logo_wxbnlw-min_n5sbju.png", }, }, + link5: { + title: "Mock Tests", + link: "/mht-cet/mock-tests", + icon: { + src: "https://res.cloudinary.com/dfyrk32ua/image/upload/v1721510815/deetnuts/logos/MHT-CET_logo_wxbnlw-min_n5sbju.png", + }, + }, }; export const metadata: Metadata = { diff --git a/components/DiscordHeader.test.tsx b/components/DiscordHeader.test.tsx new file mode 100644 index 0000000..597e2ee --- /dev/null +++ b/components/DiscordHeader.test.tsx @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import DiscordHeader from "./DiscordHeader"; + +test("DiscordHeader renders a footer-friendly Discord invite with the live external link", () => { + const markup = renderToStaticMarkup(); + + assert.match(markup, /Join our Discord for updates & support!/i); + assert.match(markup, /https:\/\/discord\.gg\/xbtqGcQ6SF/i); + assert.match(markup, /justify-center/); + assert.doesNotMatch(markup, /bg-purple-600/); +}); diff --git a/components/DiscordHeader.tsx b/components/DiscordHeader.tsx index b244bbc..5c813a3 100644 --- a/components/DiscordHeader.tsx +++ b/components/DiscordHeader.tsx @@ -3,16 +3,24 @@ import React from "react"; const DiscordHeader = () => { return ( -
- 🎉 Join our Discord for updates & support! - - Click here - +
+
+

+ + Join our Discord for updates & support! +

+ + Click here + +
); }; diff --git a/components/authbutton.tsx b/components/authbutton.tsx index c1b7bcc..ed05d82 100644 --- a/components/authbutton.tsx +++ b/components/authbutton.tsx @@ -22,12 +22,19 @@ export default async function AuthButton() {
- - - - {(user.name || "").charAt(0).toUpperCase()} - - +
diff --git a/components/footer.tsx b/components/footer.tsx index 6818b92..b8ebd5c 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { Montserrat } from "next/font/google"; +import DiscordHeader from "./DiscordHeader"; + const lato = Montserrat({ subsets: ["latin"], weight: ["400", "700", "900"], @@ -87,6 +89,8 @@ export default function Footer() { {/* Company, Helpful Links, and Legal sections remain unchanged */}
+ +
diff --git a/components/mht-cet/admin/AdminAccessDenied.tsx b/components/mht-cet/admin/AdminAccessDenied.tsx new file mode 100644 index 0000000..e41c917 --- /dev/null +++ b/components/mht-cet/admin/AdminAccessDenied.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; +import { ArrowLeft, LogIn, ShieldAlert } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { MhtCetAdminError } from "@/lib/mht-cet/admin/auth"; + +type AdminAccessDeniedProps = { + error: MhtCetAdminError; +}; + +export function AdminAccessDenied({ error }: AdminAccessDeniedProps) { + const isUnauthorized = error.status === 401; + + return ( +
+
+
+
+
+
+

+ MHT-CET Admin {error.status} +

+

+ Admin access required +

+

+ {isUnauthorized + ? "Sign in with an account that has MHT-CET admin access." + : "Your signed-in account does not have MHT-CET admin access."} +

+
+
+ +
+ {isUnauthorized ? ( + + ) : null} + +
+
+
+ ); +} diff --git a/components/mht-cet/admin/ImportBatchTable.tsx b/components/mht-cet/admin/ImportBatchTable.tsx new file mode 100644 index 0000000..c5bf4c8 --- /dev/null +++ b/components/mht-cet/admin/ImportBatchTable.tsx @@ -0,0 +1,48 @@ +type ImportBatch = { + id: string; + file_name: string; + status: string; + total_rows: number; + accepted_rows: number; + rejected_rows: number; + created_at: string; +}; + +export function ImportBatchTable({ batches }: { batches: ImportBatch[] }) { + return ( +
+ + + + + + + + + + + + + {batches.map((batch) => ( + + + + + + + + + ))} + +
FileStatusRowsAcceptedRejectedCreated
{batch.file_name}{batch.status} + {batch.total_rows} + + {batch.accepted_rows} + + {batch.rejected_rows} + + {new Date(batch.created_at).toLocaleDateString()} +
+
+ ); +} diff --git a/components/mht-cet/admin/QuestionReviewPanel.tsx b/components/mht-cet/admin/QuestionReviewPanel.tsx new file mode 100644 index 0000000..c595cb7 --- /dev/null +++ b/components/mht-cet/admin/QuestionReviewPanel.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useState } from "react"; + +export function QuestionReviewPanel({ + questionId, + sourceApproved, +}: { + questionId: string; + sourceApproved: boolean; +}) { + const [status, setStatus] = useState(null); + const [isSourceApproved, setIsSourceApproved] = useState(sourceApproved); + + async function review(action: "approved" | "rejected" | "approve_source") { + setStatus("Saving"); + const response = await fetch( + `/api/mht-cet/admin/questions/${questionId}/review`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }, + ); + + if (response.ok && action === "approve_source") { + setIsSourceApproved(true); + } + + setStatus(response.ok ? action : "Review failed"); + } + + return ( +
+ + {!isSourceApproved ? ( + + ) : null} + + {status ? {status} : null} +
+ ); +} diff --git a/components/mht-cet/mock-tests/AttemptShell.tsx b/components/mht-cet/mock-tests/AttemptShell.tsx new file mode 100644 index 0000000..1bdd613 --- /dev/null +++ b/components/mht-cet/mock-tests/AttemptShell.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { QuestionContent } from "@/components/mht-cet/questions/QuestionContent"; +import { cn } from "@/lib/utils"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { QuestionNavigator } from "./QuestionNavigator"; +import { TimerBar } from "./TimerBar"; +import type { AttemptPayload, AttemptResponse } from "./types"; + +export function AttemptShell({ attemptId }: { attemptId: string }) { + const router = useRouter(); + const [payload, setPayload] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [responses, setResponses] = useState>( + {}, + ); + const [saveState, setSaveState] = useState("Ready"); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let mounted = true; + + fetch(`/api/mht-cet/mock-tests/attempts/${attemptId}`) + .then(async (response) => { + const data = (await response.json()) as + | AttemptPayload + | { error?: { message?: string } }; + + if (!response.ok) { + throw new Error( + "error" in data + ? data.error?.message + : "Could not load this mock attempt.", + ); + } + + return data as AttemptPayload; + }) + .then((data: AttemptPayload) => { + if (!mounted) { + return; + } + + setPayload(data); + if (data.attempt.status !== "in_progress") { + router.push(`/mht-cet/mock-tests/attempts/${attemptId}/results`); + return; + } + + setResponses( + Object.fromEntries( + data.responses.map((response) => [response.question_id, response]), + ), + ); + }) + .catch((error: unknown) => { + if (!mounted) { + return; + } + + setLoadError( + error instanceof Error ? error.message : "Could not load this mock.", + ); + setSaveState("Load failed"); + }); + + return () => { + mounted = false; + }; + }, [attemptId, router]); + + const activeQuestion = payload?.questions[activeIndex]; + const activeQuestionId = activeQuestion?.question.id ?? ""; + const activeResponse = responses[activeQuestionId]; + const answeredQuestionIds = useMemo( + () => + new Set( + Object.values(responses) + .filter((response) => response.selected_option_ids.length > 0) + .map((response) => response.question_id), + ), + [responses], + ); + const markedQuestionIds = useMemo( + () => + new Set( + Object.values(responses) + .filter((response) => response.marked_for_review) + .map((response) => response.question_id), + ), + [responses], + ); + + async function saveResponse( + questionId: string, + selectedOptionIds: string[], + markedForReview = false, + ) { + setSaveState("Saving"); + setResponses((current) => ({ + ...current, + [questionId]: { + question_id: questionId, + selected_option_ids: selectedOptionIds, + visited: true, + marked_for_review: markedForReview, + time_spent_seconds: current[questionId]?.time_spent_seconds ?? 0, + }, + })); + + try { + const response = await fetch( + `/api/mht-cet/mock-tests/attempts/${attemptId}/responses`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + questionId, + selectedOptionIds, + visited: true, + markedForReview, + }), + }, + ); + + setSaveState(response.ok ? "Saved" : "Save failed"); + } catch { + setSaveState("Save failed"); + } + } + + async function submitAttempt() { + setSaveState("Submitting"); + const response = await fetch( + `/api/mht-cet/mock-tests/attempts/${attemptId}/submit`, + { + method: "POST", + }, + ); + + if (response.ok) { + router.push(`/mht-cet/mock-tests/attempts/${attemptId}/results`); + return; + } + + setSaveState("Submit failed"); + } + + if (loadError) { + return ( +
+

Mock unavailable

+

{loadError}

+ +
+ ); + } + + if (!payload || !activeQuestion) { + return
Loading mock...
; + } + + return ( +
+ +
+
+
+ + Question {activeIndex + 1} / {payload.questions.length} + + {saveState} +
+ +
+ {(activeQuestion.question.options ?? []) + .toSorted( + (first, second) => first.option_order - second.option_order, + ) + .map((option) => { + const selected = + activeResponse?.selected_option_ids.includes(option.id) ?? + false; + return ( + + ); + })} +
+
+ + + +
+
+ +
+
+ ); +} diff --git a/components/mht-cet/mock-tests/MockBuilder.tsx b/components/mht-cet/mock-tests/MockBuilder.tsx new file mode 100644 index 0000000..540e838 --- /dev/null +++ b/components/mht-cet/mock-tests/MockBuilder.tsx @@ -0,0 +1,376 @@ +"use client"; + +import chapters from "@/data/mht-cet/question-bank/chapters.json"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import type { MhtCetMockMode } from "@/lib/mht-cet/mock-tests/config"; +import type { MockTestAvailability } from "@/lib/mht-cet/mock-tests/supabase"; +import type { MhtCetSubject } from "@/lib/mht-cet/schema"; +import { cn } from "@/lib/utils"; +import { + Calculator, + FlaskConical, + Gauge, + SlidersHorizontal, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; + +const modeOptions: Array<{ + mode: MhtCetMockMode; + label: string; + icon: typeof Gauge; +}> = [ + { mode: "full_pcm", label: "Full PCM", icon: Gauge }, + { mode: "mathematics", label: "Math", icon: Calculator }, + { mode: "physics_chemistry", label: "Phy/Chem", icon: FlaskConical }, + { mode: "custom", label: "Custom", icon: SlidersHorizontal }, +]; + +const subjectLabels: Record = { + mathematics: "Mathematics", + physics: "Physics", + chemistry: "Chemistry", +}; + +const allSubjects = Object.keys(subjectLabels) as MhtCetSubject[]; + +type ChapterSeed = { + subject: MhtCetSubject; + standard: number; + slug: string; + name: string; +}; + +function subjectsForMode(mode: MhtCetMockMode): MhtCetSubject[] { + if (mode === "mathematics") { + return ["mathematics"]; + } + + if (mode === "physics_chemistry") { + return ["physics", "chemistry"]; + } + + return ["mathematics", "physics", "chemistry"]; +} + +function getSubjectQuestionCount( + availability: MockTestAvailability, + subjects: readonly MhtCetSubject[], +) { + return subjects.reduce( + (total, subject) => total + availability.subjectCounts[subject], + 0, + ); +} + +function canRunPresetMode( + availability: MockTestAvailability, + mode: MhtCetMockMode, +) { + if (mode === "custom") { + return availability.totalApprovedQuestions > 0; + } + + if (mode === "mathematics") { + return availability.subjectCounts.mathematics >= 50; + } + + if (mode === "physics_chemistry") { + return ( + availability.subjectCounts.physics >= 50 && + availability.subjectCounts.chemistry >= 50 + ); + } + + return allSubjects.every( + (subject) => availability.subjectCounts[subject] >= 50, + ); +} + +function getInitialState(availability: MockTestAvailability) { + if (canRunPresetMode(availability, "full_pcm")) { + return { + mode: "full_pcm" as MhtCetMockMode, + subjects: subjectsForMode("full_pcm"), + questionCount: 150, + durationMinutes: 180, + }; + } + + const availableSubjects = allSubjects.filter( + (subject) => availability.subjectCounts[subject] > 0, + ); + const subjects = + availableSubjects.length > 0 ? availableSubjects : allSubjects; + const availableQuestionCount = getSubjectQuestionCount( + availability, + subjects, + ); + + return { + mode: "custom" as MhtCetMockMode, + subjects, + questionCount: Math.max(1, Math.min(30, availableQuestionCount)), + durationMinutes: availableQuestionCount <= 5 ? 15 : 60, + }; +} + +function getPreferredYear(availability: MockTestAvailability) { + const years = Object.entries(availability.yearCounts).sort( + ([, firstCount], [, secondCount]) => secondCount - firstCount, + ); + + return years[0]?.[0] ?? ""; +} + +export function MockBuilder({ + availability, +}: { + availability: MockTestAvailability; +}) { + const router = useRouter(); + const initialState = useMemo( + () => getInitialState(availability), + [availability], + ); + const [mode, setMode] = useState(initialState.mode); + const [subjects, setSubjects] = useState( + initialState.subjects, + ); + const [chapterSlugs, setChapterSlugs] = useState([]); + const [questionCount, setQuestionCount] = useState( + initialState.questionCount, + ); + const [durationMinutes, setDurationMinutes] = useState( + initialState.durationMinutes, + ); + const [year, setYear] = useState(getPreferredYear(availability)); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const selectedSubjectQuestionCount = getSubjectQuestionCount( + availability, + subjects, + ); + const maxQuestionCount = Math.max(1, selectedSubjectQuestionCount); + + const visibleChapters = useMemo( + () => + (chapters as ChapterSeed[]).filter((chapter) => + subjects.includes(chapter.subject), + ), + [subjects], + ); + + function updateMode(nextMode: MhtCetMockMode) { + if (!canRunPresetMode(availability, nextMode)) { + return; + } + + const nextSubjects = subjectsForMode(nextMode); + setMode(nextMode); + setSubjects(nextSubjects); + setChapterSlugs([]); + setQuestionCount( + nextMode === "full_pcm" ? 150 : nextMode === "mathematics" ? 50 : 100, + ); + setDurationMinutes(nextMode === "full_pcm" ? 180 : 90); + } + + function toggleSubject(subject: MhtCetSubject) { + const nextSubjects = subjects.includes(subject) + ? subjects.filter((item) => item !== subject) + : [...subjects, subject]; + setSubjects(nextSubjects.length > 0 ? nextSubjects : [subject]); + setMode("custom"); + setQuestionCount((current) => + Math.min( + current, + Math.max(1, getSubjectQuestionCount(availability, nextSubjects)), + ), + ); + setChapterSlugs((current) => + current.filter((slug) => + (chapters as ChapterSeed[]).some( + (chapter) => + chapter.slug === slug && nextSubjects.includes(chapter.subject), + ), + ), + ); + } + + function toggleChapter(slug: string) { + setChapterSlugs((current) => + current.includes(slug) + ? current.filter((item) => item !== slug) + : [...current, slug], + ); + } + + async function startMock() { + if (availability.totalApprovedQuestions === 0) { + setError("No approved questions are available yet."); + return; + } + + setIsSubmitting(true); + setError(null); + + const parsedYear = year ? Number(year) : undefined; + const response = await fetch("/api/mht-cet/mock-tests", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode, + subjects, + chapterSlugs, + questionCount, + durationSeconds: durationMinutes * 60, + year: Number.isFinite(parsedYear) ? parsedYear : undefined, + examGroup: "pcm", + }), + }); + const payload = (await response.json()) as { + attemptId?: string; + error?: { message?: string }; + }; + + setIsSubmitting(false); + + if (!response.ok || !payload.attemptId) { + setError( + payload.error?.message === + "Not enough approved questions for this mock configuration." + ? `Only ${selectedSubjectQuestionCount} approved questions are available for this selection.` + : (payload.error?.message ?? "Could not start this mock."), + ); + return; + } + + router.push(`/mht-cet/mock-tests/attempts/${payload.attemptId}`); + } + + return ( +
+
+ {modeOptions.map((option) => { + const Icon = option.icon; + const disabled = !canRunPresetMode(availability, option.mode); + return ( + + ); + })} +
+ +
+

Mock Setup

+

+ {availability.totalApprovedQuestions === 0 + ? "No approved questions are available yet." + : `${selectedSubjectQuestionCount} approved questions available for the selected subjects.`} +

+
+ {Object.entries(subjectLabels).map(([subject, label]) => ( + + ))} +
+
+ + + +
+
+ +
+

Chapters

+
+ {visibleChapters.map((chapter) => ( + + ))} +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + +
+ ); +} diff --git a/components/mht-cet/mock-tests/QuestionNavigator.tsx b/components/mht-cet/mock-tests/QuestionNavigator.tsx new file mode 100644 index 0000000..63487f8 --- /dev/null +++ b/components/mht-cet/mock-tests/QuestionNavigator.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export function QuestionNavigator({ + activeIndex, + answeredQuestionIds, + markedQuestionIds, + questionIds, + onSelect, +}: { + activeIndex: number; + answeredQuestionIds: Set; + markedQuestionIds: Set; + questionIds: string[]; + onSelect: (index: number) => void; +}) { + return ( +
+ {questionIds.map((questionId, index) => ( + + ))} +
+ ); +} diff --git a/components/mht-cet/mock-tests/ResultsSummary.tsx b/components/mht-cet/mock-tests/ResultsSummary.tsx new file mode 100644 index 0000000..91d71c8 --- /dev/null +++ b/components/mht-cet/mock-tests/ResultsSummary.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { QuestionContent } from "@/components/mht-cet/questions/QuestionContent"; +import { useEffect, useState } from "react"; + +import { StatsTables } from "./StatsTables"; +import type { ResultsPayload } from "./types"; + +export function ResultsSummary({ attemptId }: { attemptId: string }) { + const [payload, setPayload] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(`/api/mht-cet/mock-tests/attempts/${attemptId}/results`) + .then(async (response) => { + const data = (await response.json()) as + | ResultsPayload + | { error?: { message?: string } }; + + if (!response.ok) { + throw new Error( + "error" in data + ? data.error?.message + : "Could not load these results.", + ); + } + + return data as ResultsPayload; + }) + .then((data) => setPayload(data)) + .catch((loadError: unknown) => + setError( + loadError instanceof Error + ? loadError.message + : "Could not load these results.", + ), + ); + }, [attemptId]); + + if (error) { + return ( +
+

Results unavailable

+

{error}

+
+ ); + } + + if (!payload) { + return
Loading results...
; + } + + const summaryCards = [ + ["Score", `${payload.score.rawScore} / ${payload.score.maxScore}`], + ["Correct", payload.score.correctCount], + ["Wrong", payload.score.wrongCount], + ["Unanswered", payload.score.unansweredCount], + ]; + + function optionLabel( + optionIds: string[], + options: ResultsPayload["review"][number]["question"]["options"], + ) { + if (optionIds.length === 0) { + return "Unanswered"; + } + + return optionIds + .map((optionId) => { + const option = options.find((item) => item.id === optionId); + return option + ? String.fromCharCode(64 + option.option_order) + : "Unknown"; + }) + .join(", "); + } + + return ( +
+
+

Mock Results

+

Raw score and DEETNUTS attempt stats.

+
+
+ {summaryCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+

Question Review

+ {payload.review.map((item) => { + const answered = item.selectedOptionIds.length > 0; + const correct = + answered && + item.correctOptionIds.length === item.selectedOptionIds.length && + item.correctOptionIds.every((optionId) => + item.selectedOptionIds.includes(optionId), + ); + + return ( +
+
+

+ Question {item.position} +

+ + {!answered ? "Unanswered" : correct ? "Correct" : "Review"} + +
+ +
+ + + + + + + + + + + +
+ Selected + + {optionLabel( + item.selectedOptionIds, + item.question.options, + )} +
+ Correct + + {optionLabel( + item.correctOptionIds, + item.question.options, + )} +
+
+ {item.explanation ? ( + + ) : ( +

+ Explanation is not available for this question. +

+ )} +
+ ); + })} +
+
+ ); +} diff --git a/components/mht-cet/mock-tests/StatsTables.tsx b/components/mht-cet/mock-tests/StatsTables.tsx new file mode 100644 index 0000000..fa62691 --- /dev/null +++ b/components/mht-cet/mock-tests/StatsTables.tsx @@ -0,0 +1,81 @@ +import type { + ScoreBreakdown, + ScoreAttemptResult, +} from "@/lib/mht-cet/mock-tests/score-attempt"; + +function formatPercent(value: number) { + return `${Math.round(value * 100)}%`; +} + +function StatsTable({ + rows, +}: { + rows: Array<{ label: string; stats: ScoreBreakdown }>; +}) { + return ( +
+ + + + + + + + + + + + + {rows.map(({ label, stats }) => ( + + + + + + + + + ))} + +
NameScoreCorrectWrongUnansweredAccuracy
{label} + {stats.rawScore} / {stats.maxScore} + + {stats.correctCount} + + {stats.wrongCount} + + {stats.unansweredCount} + + {formatPercent(stats.accuracy)} +
+
+ ); +} + +export function StatsTables({ score }: { score: ScoreAttemptResult }) { + const subjectRows = Object.entries(score.subjectStats).map( + ([label, stats]) => ({ + label, + stats, + }), + ); + const chapterRows = Object.entries(score.chapterStats).map( + ([label, stats]) => ({ + label, + stats, + }), + ); + + return ( +
+
+

Subjects

+ +
+
+

Chapters

+ +
+
+ ); +} diff --git a/components/mht-cet/mock-tests/TimerBar.tsx b/components/mht-cet/mock-tests/TimerBar.tsx new file mode 100644 index 0000000..c807f48 --- /dev/null +++ b/components/mht-cet/mock-tests/TimerBar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Clock } from "lucide-react"; +import { useEffect, useState } from "react"; + +function formatTime(totalSeconds: number) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function TimerBar({ + durationSeconds, + endsAt, +}: { + durationSeconds: number; + endsAt: string; +}) { + const [remainingSeconds, setRemainingSeconds] = useState(() => + Math.max(0, Math.floor((Date.parse(endsAt) - Date.now()) / 1000)), + ); + + useEffect(() => { + const interval = window.setInterval(() => { + setRemainingSeconds( + Math.max(0, Math.floor((Date.parse(endsAt) - Date.now()) / 1000)), + ); + }, 1000); + + return () => window.clearInterval(interval); + }, [endsAt]); + + const progress = + durationSeconds > 0 ? (remainingSeconds / durationSeconds) * 100 : 0; + + return ( +
+
+ + {formatTime(remainingSeconds)} +
+
+
+
+
+ ); +} diff --git a/components/mht-cet/mock-tests/types.ts b/components/mht-cet/mock-tests/types.ts new file mode 100644 index 0000000..c9e2a31 --- /dev/null +++ b/components/mht-cet/mock-tests/types.ts @@ -0,0 +1,66 @@ +import type { QuestionBlock } from "@/lib/mht-cet/questions/content-schema"; +import type { ScoreAttemptResult } from "@/lib/mht-cet/mock-tests/score-attempt"; +import type { MhtCetSubject } from "@/lib/mht-cet/schema"; + +export type AttemptOption = { + id: string; + option_order: number; + body: QuestionBlock[]; + body_text?: string; +}; + +export type AttemptQuestion = { + position: number; + marks: number; + subject: MhtCetSubject; + question: { + id: string; + body: QuestionBlock[]; + body_text?: string; + options?: AttemptOption[]; + }; +}; + +export type AttemptResponse = { + question_id: string; + selected_option_ids: string[]; + visited: boolean; + marked_for_review: boolean; + time_spent_seconds: number; +}; + +export type AttemptPayload = { + attempt: { + id: string; + status: string; + ends_at: string; + duration_seconds: number; + question_count: number; + }; + questions: AttemptQuestion[]; + responses: AttemptResponse[]; +}; + +export type ResultsPayload = { + attempt: { + id: string; + status: string; + score_raw: number; + max_score: number; + correct_count: number; + wrong_count: number; + unanswered_count: number; + }; + score: ScoreAttemptResult; + review: Array<{ + position: number; + question: { + id: string; + body: QuestionBlock[]; + options: AttemptOption[]; + }; + selectedOptionIds: string[]; + correctOptionIds: string[]; + explanation: QuestionBlock[] | null; + }>; +}; diff --git a/components/mht-cet/questions/QuestionContent.test.tsx b/components/mht-cet/questions/QuestionContent.test.tsx new file mode 100644 index 0000000..bf9c157 --- /dev/null +++ b/components/mht-cet/questions/QuestionContent.test.tsx @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { QuestionContent } from "./QuestionContent"; +import type { QuestionBlock } from "@/lib/mht-cet/questions/content-schema"; + +test("paragraph text appears", () => { + const html = renderToStaticMarkup( + , + ); + + assert.match(html, /Read the stem carefully\./); +}); + +test("display math renders KaTeX HTML and MathML", () => { + const html = renderToStaticMarkup( + , + ); + + assert.match(html, /class="katex/); + assert.match(html, / { + assert.doesNotThrow(() => + renderToStaticMarkup( + , + ), + ); +}); + +test("raw HTML-like text is escaped", () => { + const html = renderToStaticMarkup( + not html" }]} + />, + ); + + assert.match(html, /<strong>not html<\/strong>/); + assert.doesNotMatch(html, /not html<\/strong>/); +}); + +test("image block requires alt text", () => { + const unsafeImage = { + type: "image", + src: "/fixture.png", + alt: "", + width: 320, + height: 180, + } as QuestionBlock; + const html = renderToStaticMarkup(); + + assert.match(html, /Image alt text required/); + assert.doesNotMatch(html, / + {block.text} +

+ ); + case "math": + return ( + + ); + case "image": + if (!block.alt.trim()) { + return ( +

+ Image alt text required +

+ ); + } + + return ( + {block.alt} + ); + case "table": + return ( +
+ + {block.caption ? : null} + + {block.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{block.caption}
{cell}
+
+ ); + case "list": { + const ListTag = block.ordered ? "ol" : "ul"; + + return ( + + {block.items.map((item, itemIndex) => ( +
  • {item}
  • + ))} +
    + ); + } + default: + return null; + } +} + +export function QuestionContent({ + blocks, + variant = "question", +}: QuestionContentProps) { + return ( +
    + {blocks.map((block, index) => renderBlock(block, index))} +
    + ); +} diff --git a/components/mht-cet/questions/question-content.css b/components/mht-cet/questions/question-content.css new file mode 100644 index 0000000..6923fbb --- /dev/null +++ b/components/mht-cet/questions/question-content.css @@ -0,0 +1,61 @@ +.mht-cet-question-content { + display: grid; + gap: 0.75rem; + overflow-wrap: anywhere; +} + +.mht-cet-question-content__paragraph { + margin: 0; + line-height: 1.65; +} + +.mht-cet-question-content__math { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; +} + +.mht-cet-question-content__math--display { + display: block; + padding-block: 0.25rem; +} + +.mht-cet-question-content__table-wrap { + max-width: 100%; + overflow-x: auto; +} + +.mht-cet-question-content__table { + width: max-content; + min-width: 100%; + border-collapse: collapse; +} + +.mht-cet-question-content__table caption { + padding-block-end: 0.5rem; + text-align: left; +} + +.mht-cet-question-content__table td { + border: 2px solid #000; + padding: 0.5rem 0.75rem; + vertical-align: top; +} + +.mht-cet-question-content__list { + margin: 0; + padding-inline-start: 1.5rem; +} + +.mht-cet-question-content__image { + display: block; + height: auto; + max-width: 100%; + border: 2px solid #000; +} + +.mht-cet-question-content__warning { + margin: 0; + color: #9f1239; + font-weight: 700; +} diff --git a/components/ui/community-partners.tsx b/components/ui/community-partners.tsx index 458e846..75bdafe 100644 --- a/components/ui/community-partners.tsx +++ b/components/ui/community-partners.tsx @@ -21,8 +21,10 @@ export default function CommunityPartners() {
    -
    - COMMUNITY PARTNERS +
    +
    + COMMUNITY PARTNERS +

    Get Advice From Real Students diff --git a/data/mht-cet/question-bank/chapters.json b/data/mht-cet/question-bank/chapters.json new file mode 100644 index 0000000..8fe8074 --- /dev/null +++ b/data/mht-cet/question-bank/chapters.json @@ -0,0 +1,191 @@ +[ + { + "subject": "physics", + "standard": 11, + "slug": "motion-in-a-plane", + "name": "Motion in a plane", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "laws-of-motion", + "name": "Laws of Motion", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "gravitation", + "name": "Gravitation", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "thermal-properties-of-matter", + "name": "Thermal properties of matter", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "sound", + "name": "Sound", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "optics", + "name": "Optics", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "electrostatics", + "name": "Electrostatics", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "semiconductors", + "name": "Semiconductors", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "some-basic-concepts-of-chemistry", + "name": "Some Basic concepts of chemistry", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "structure-of-atom", + "name": "Structure of atom", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "chemical-bonding", + "name": "Chemical Bonding", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "redox-reactions", + "name": "Redox reactions", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "elements-of-group-1-and-2", + "name": "Elements of group 1 and 2", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "states-of-matter", + "name": "States of Matter (Gaseous and Liquids)", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "adsorption-and-colloids", + "name": "Adsorption and colloids (Surface Chemistry)", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "hydrocarbons", + "name": "Hydrocarbons", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "basic-principles-of-organic-chemistry", + "name": "Basic principles of organic chemistry", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "trigonometry-ii", + "name": "Trigonometry II", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "straight-line", + "name": "Straight Line", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "circle", + "name": "Circle", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "measures-of-dispersion", + "name": "Measures of Dispersion", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "probability", + "name": "Probability", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "complex-numbers", + "name": "Complex Numbers", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "permutations-and-combinations", + "name": "Permutations and Combinations", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "functions", + "name": "Functions", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "limits", + "name": "Limits", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "continuity", + "name": "Continuity", + "official": true + } +] diff --git a/data/mht-cet/question-bank/practice-2026-original.json b/data/mht-cet/question-bank/practice-2026-original.json new file mode 100644 index 0000000..4aa87bb --- /dev/null +++ b/data/mht-cet/question-bank/practice-2026-original.json @@ -0,0 +1,1173 @@ +[ + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "motion-in-a-plane", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A projectile is launched with speed 20 m/s at 30 degrees above the horizontal. Taking g = 10 m/s^2, what is its horizontal range?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "10 m" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "20 m" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "20sqrt(3) m" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "40 m" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Range is u^2 sin(2theta) / g = 400 sin 60 / 10 = 20sqrt(3) m." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "laws-of-motion", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A 4 kg block is pulled by a 20 N horizontal force. If friction is 4 N opposite to motion, what is the acceleration?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "2 m/s^2" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "3 m/s^2" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "4 m/s^2" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "5 m/s^2" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Net force is 20 - 4 = 16 N, so acceleration is F/m = 16/4 = 4 m/s^2." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "gravitation", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A body weighs W on Earth's surface. What is its weight at a height equal to Earth's radius above the surface?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "W" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "W/2" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "W/4" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "4W" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "At height R, distance from Earth's center is 2R, so gravitational force becomes W/(2^2) = W/4." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "thermal-properties-of-matter", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "How much heat is needed to raise the temperature of 0.5 kg of water by 10 degrees Celsius? Take specific heat of water as 4200 J kg^-1 K^-1." + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "2100 J" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "4200 J" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "21000 J" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "42000 J" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Heat Q = mcDeltaT = 0.5 x 4200 x 10 = 21000 J." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "sound", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A sound wave has frequency 500 Hz and wavelength 0.68 m. What is its speed?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "170 m/s" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "250 m/s" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "340 m/s" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "500 m/s" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Wave speed is frequency times wavelength: v = 500 x 0.68 = 340 m/s." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "optics", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A convex lens has focal length 20 cm. An object is placed 30 cm in front of it. What is the image distance?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "12 cm" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "30 cm" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "60 cm" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "90 cm" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Using 1/f = 1/v - 1/u with f = 20 cm and u = -30 cm gives v = 60 cm." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "electrostatics", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the electric field 0.30 m from a point charge of 2 microcoulomb? Take k = 9 x 10^9 N m^2 C^-2." + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "2 x 10^3 N/C" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "2 x 10^4 N/C" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "2 x 10^5 N/C" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "2 x 10^6 N/C" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "E = kq/r^2 = (9 x 10^9)(2 x 10^-6)/(0.30)^2 = 2 x 10^5 N/C." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "semiconductors", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "In an intrinsic semiconductor at thermal equilibrium, which statement is correct?" + } + ], + "options": [ + { + "id": "a", + "body": [ + { "type": "paragraph", "text": "Electron concentration is zero." } + ] + }, + { + "id": "b", + "body": [{ "type": "paragraph", "text": "Hole concentration is zero." }] + }, + { + "id": "c", + "body": [ + { + "type": "paragraph", + "text": "Electron and hole concentrations are equal." + } + ] + }, + { + "id": "d", + "body": [ + { + "type": "paragraph", + "text": "Only impurity atoms conduct current." + } + ] + } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Intrinsic semiconductors generate electrons and holes in equal numbers." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "motion-in-a-plane", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A vector of magnitude 10 units makes an angle of 60 degrees with the positive x-axis. What is its y-component?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "5" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "5sqrt(3)" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "10" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "10sqrt(3)" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "The y-component is A sin theta = 10 sin 60 = 5sqrt(3)." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "laws-of-motion", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A constant force of 5 N acts on a body for 2 s. What impulse is delivered?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "2.5 N s" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "5 N s" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "10 N s" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "20 N s" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Impulse equals force times time: J = Ft = 5 x 2 = 10 N s." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "some-basic-concepts-of-chemistry", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the mass of 0.5 mol of CO2? Use molar mass of CO2 = 44 g/mol." + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "11 g" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "22 g" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "44 g" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "88 g" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "Mass = moles x molar mass = 0.5 x 44 = 22 g." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "structure-of-atom", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the maximum number of electrons that can be present in the shell with principal quantum number n = 3?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "6" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "8" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "18" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "32" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "A shell can hold 2n^2 electrons. For n = 3, this is 2 x 9 = 18." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "chemical-bonding", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "Which type of bond is primarily present in magnesium chloride, MgCl2?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "Ionic bond" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "Metallic bond" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "Hydrogen bond" }] }, + { + "id": "d", + "body": [{ "type": "paragraph", "text": "Coordinate covalent bond" }] + } + ], + "correctOptionIds": ["a"], + "explanation": [ + { + "type": "paragraph", + "text": "Magnesium forms Mg2+ and chlorine forms Cl-, so MgCl2 is mainly ionic." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "redox-reactions", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the oxidation state of manganese in KMnO4?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "+2" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "+4" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "+6" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "+7" }] } + ], + "correctOptionIds": ["d"], + "explanation": [ + { + "type": "paragraph", + "text": "For neutral KMnO4, +1 + x + 4(-2) = 0, so x = +7." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "elements-of-group-1-and-2", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "Elements of group 2 commonly form which type of ions?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "+1 cations" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "+2 cations" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "-1 anions" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "-2 anions" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "Group 2 atoms have two valence electrons and commonly lose both to form +2 ions." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "states-of-matter", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A gas occupies 3 L at 2 atm at constant temperature. What volume will it occupy at 1 atm?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "1.5 L" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "3 L" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "4 L" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "6 L" }] } + ], + "correctOptionIds": ["d"], + "explanation": [ + { + "type": "paragraph", + "text": "By Boyle's law, P1V1 = P2V2, so V2 = (2 x 3)/1 = 6 L." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "adsorption-and-colloids", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "For physisorption of a gas on a solid surface, what generally happens when temperature is increased?" + } + ], + "options": [ + { + "id": "a", + "body": [ + { "type": "paragraph", "text": "Adsorption generally decreases." } + ] + }, + { + "id": "b", + "body": [ + { "type": "paragraph", "text": "Adsorption always becomes infinite." } + ] + }, + { + "id": "c", + "body": [{ "type": "paragraph", "text": "No desorption can occur." }] + }, + { + "id": "d", + "body": [{ "type": "paragraph", "text": "Surface area becomes zero." }] + } + ], + "correctOptionIds": ["a"], + "explanation": [ + { + "type": "paragraph", + "text": "Physisorption is usually exothermic, so raising temperature tends to reduce adsorption." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "hydrocarbons", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the molecular formula of ethene?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "C2H2" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "C2H4" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "C2H6" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "C3H6" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "Ethene is the two-carbon alkene, so its formula is C2H4." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "basic-principles-of-organic-chemistry", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "In organic chemistry, the inductive effect is best described as which phenomenon?" + } + ], + "options": [ + { + "id": "a", + "body": [ + { + "type": "paragraph", + "text": "Temporary electron movement only in pi bonds" + } + ] + }, + { + "id": "b", + "body": [ + { + "type": "paragraph", + "text": "Permanent electron displacement through sigma bonds" + } + ] + }, + { + "id": "c", + "body": [ + { + "type": "paragraph", + "text": "Complete breaking of all sigma bonds" + } + ] + }, + { + "id": "d", + "body": [ + { + "type": "paragraph", + "text": "Change of neutron number in a nucleus" + } + ] + } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "The inductive effect is a permanent polarization transmitted through sigma bonds due to electronegativity differences." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "redox-reactions", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "In the reaction Zn + CuSO4 -> ZnSO4 + Cu, which species is oxidized?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "Zn" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "Cu2+" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "SO4^2-" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "Cu" }] } + ], + "correctOptionIds": ["a"], + "explanation": [ + { + "type": "paragraph", + "text": "Zinc changes from oxidation state 0 to +2, so zinc is oxidized." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "trigonometry-ii", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the value of sin 30 degrees + cos 60 degrees?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "0" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "1/2" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "1" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "2" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "sin 30 degrees = 1/2 and cos 60 degrees = 1/2, so the sum is 1." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "straight-line", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the slope of the line passing through points (1, 2) and (3, 6)?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "1" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "2" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "3" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "4" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { "type": "paragraph", "text": "Slope = (6 - 2)/(3 - 1) = 4/2 = 2." } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "circle", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "For the circle x^2 + y^2 - 4x + 6y - 12 = 0, what is its radius?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "3" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "4" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "5" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "6" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Completing squares gives (x - 2)^2 + (y + 3)^2 = 25, so the radius is 5." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "measures-of-dispersion", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the variance of the data set 2, 4, 6?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "2/3" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "4/3" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "8/3" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "4" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Mean is 4. Squared deviations are 4, 0, and 4, so variance is 8/3." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "probability", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "Two fair coins are tossed. What is the probability of getting exactly one head?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "1/4" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "1/2" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "3/4" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "1" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "The outcomes with exactly one head are HT and TH, so probability is 2/4 = 1/2." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "complex-numbers", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "What is the modulus of the complex number 3 + 4i?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "1" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "5" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "7" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "25" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "Modulus is sqrt(3^2 + 4^2) = sqrt(25) = 5." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "permutations-and-combinations", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [{ "type": "paragraph", "text": "What is the value of 5P2?" }], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "10" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "20" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "25" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "120" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { "type": "paragraph", "text": "5P2 = 5! / 3! = 5 x 4 = 20." } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "functions", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "If f(x) = 2x - 1 and g(x) = x^2, what is f(g(3))?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "8" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "15" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "17" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "18" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { "type": "paragraph", "text": "g(3) = 9, and f(9) = 2(9) - 1 = 17." } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "limits", + "year": 2026, + "examGroup": "pcm", + "difficulty": "easy", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "Evaluate the limit as x tends to 1 of (x^2 - 1)/(x - 1)." + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "0" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "1" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "2" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "Does not exist" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Factor x^2 - 1 = (x - 1)(x + 1), so the limit is x + 1 at x = 1, which is 2." + } + ] + }, + { + "source": { + "title": "DEETNUTS Original MHT-CET Practice Set 2026", + "sourceType": "manual_entry", + "sourceUrl": "https://github.com/kewonit/deetnuts/blob/main/data/mht-cet/question-bank/practice-2026-original.json", + "licenseNote": "Original DEETNUTS practice questions authored for 2026 syllabus-aligned mock practice; not official MHT-CET question paper content.", + "year": 2026, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "continuity", + "year": 2026, + "examGroup": "pcm", + "difficulty": "medium", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "The function f(x) = ax + 1 for x < 2 and f(x) = x^2 for x >= 2 is continuous at x = 2. What is a?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "1/2" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "1" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "3/2" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "2" }] } + ], + "correctOptionIds": ["c"], + "explanation": [ + { + "type": "paragraph", + "text": "Continuity at 2 requires 2a + 1 = 4, so a = 3/2." + } + ] + } +] diff --git a/data/mht-cet/question-bank/sample-fixture.json b/data/mht-cet/question-bank/sample-fixture.json new file mode 100644 index 0000000..4f59c07 --- /dev/null +++ b/data/mht-cet/question-bank/sample-fixture.json @@ -0,0 +1,108 @@ +[ + { + "source": { + "title": "Local MHT-CET Renderer Fixture", + "sourceType": "test_fixture", + "fileName": "sample-fixture.json", + "fileSha256": "1111111111111111111111111111111111111111111111111111111111111111", + "licenseNote": "Local test fixture for renderer and scoring development; not official MHT-CET content.", + "year": 2025, + "examGroup": "pcm" + }, + "subject": "mathematics", + "chapterSlug": "functions", + "year": 2025, + "examGroup": "pcm", + "marks": 2, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "For a local fixture function f(x) = x + 3, what is f(2)?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "4" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "5" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "6" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "7" }] } + ], + "correctOptionIds": ["b"], + "explanation": [{ "type": "paragraph", "text": "Substitute x = 2." }] + }, + { + "source": { + "title": "Local MHT-CET Renderer Fixture", + "sourceType": "test_fixture", + "fileName": "sample-fixture.json", + "fileSha256": "1111111111111111111111111111111111111111111111111111111111111111", + "licenseNote": "Local test fixture for renderer and scoring development; not official MHT-CET content.", + "year": 2025, + "examGroup": "pcm" + }, + "subject": "physics", + "chapterSlug": "motion-in-a-plane", + "year": 2025, + "examGroup": "pcm", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "A local fixture object travels 20 m in 5 s. What is its average speed?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "2 m/s" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "4 m/s" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "5 m/s" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "10 m/s" }] } + ], + "correctOptionIds": ["b"], + "explanation": [ + { + "type": "paragraph", + "text": "Average speed is distance divided by time." + } + ] + }, + { + "source": { + "title": "Local MHT-CET Renderer Fixture", + "sourceType": "test_fixture", + "fileName": "sample-fixture.json", + "fileSha256": "1111111111111111111111111111111111111111111111111111111111111111", + "licenseNote": "Local test fixture for renderer and scoring development; not official MHT-CET content.", + "year": 2025, + "examGroup": "pcm" + }, + "subject": "chemistry", + "chapterSlug": "some-basic-concepts-of-chemistry", + "year": 2025, + "examGroup": "pcm", + "marks": 1, + "negativeMarks": 0, + "questionType": "single_correct", + "body": [ + { + "type": "paragraph", + "text": "In a local fixture sample, how many particles are in one mole?" + } + ], + "options": [ + { "id": "a", "body": [{ "type": "paragraph", "text": "6.022 x 10^23" }] }, + { "id": "b", "body": [{ "type": "paragraph", "text": "3.011 x 10^23" }] }, + { "id": "c", "body": [{ "type": "paragraph", "text": "1.000 x 10^23" }] }, + { "id": "d", "body": [{ "type": "paragraph", "text": "9.810 x 10^23" }] } + ], + "correctOptionIds": ["a"], + "explanation": [ + { + "type": "paragraph", + "text": "One mole contains Avogadro's number of particles." + } + ] + } +] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 37d4742..c320592 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,6 +123,10 @@ Primary tables: The API surface supports all three rounds. The checked-in `app/mht-cet/all-india-cutoffs/page.tsx` currently renders a single visible round tab, while the API handlers and alternate `page-optimized.tsx` retain broader round support. +### MHT-CET Mock Tests + +Testing-platform tables use Supabase-first access. Question imports are source-audited and reviewed before publication. Answer keys are stored separately from public question bodies so students cannot retrieve correct answers during active attempts. + ### Predictions The predictions module is dataset-backed rather than model-backed at runtime. diff --git a/docs/MHT_CET_TESTING_PLATFORM_SOURCES.md b/docs/MHT_CET_TESTING_PLATFORM_SOURCES.md new file mode 100644 index 0000000..47bdac6 --- /dev/null +++ b/docs/MHT_CET_TESTING_PLATFORM_SOURCES.md @@ -0,0 +1,24 @@ +# MHT-CET Testing Platform Sources + +## Official Sources Checked + +- CET Cell home: https://cetcell.mahacet.org/ +- Syllabus index: https://cetcell.mahacet.org/syllabus-and-marking-scheme/ +- 2024 technical syllabus PDF: https://cetcell.mahacet.org/wp-content/uploads/2023/08/Technical_Education_CET_syllabus2024-25.pdf +- 2026 normalization PDF: https://cetcell.mahacet.org/wp-content/uploads/2023/12/MHT-CET-2026-Result-Processing-Methodology.pdf +- 2025 mock-test links PDF: https://cetcell.mahacet.org/wp-content/uploads/2023/12/Mocktest_links-3.pdf +- 2025 PCM objection notice: https://cetcell.mahacet.org/wp-content/uploads/2023/12/Notice_OT_-MHT-CET-PCM.pdf + +## Import Rule + +Public mocks may only use questions whose source row is approved and whose imported question row is approved. +Candidate-login or time-window material must be imported only from files supplied by an authorized operator. +Test fixtures must be marked with source_type `test_fixture` and must not be mixed with production mocks. + +## Original Practice Content + +`data/mht-cet/question-bank/practice-2026-original.json` contains DEETNUTS-authored practice questions for the 2026 practice bank. These rows use `sourceType: "manual_entry"` and are labeled as original syllabus-aligned practice content, not official MHT-CET past-paper or official mock content. Use `npm run seed:mht-cet-practice` to seed the practice rows into an environment with valid Supabase credentials. + +## Product Boundary + +DEETNUTS should not label question content as official or year-wise unless a source URL, source file hash, or permission note proves it. The platform can automate validation, duplicate detection, rendering checks, and review workflows, but publication still requires source and question approval. diff --git a/docs/superpowers/plans/2026-04-25-mht-cet-testing-platform.md b/docs/superpowers/plans/2026-04-25-mht-cet-testing-platform.md new file mode 100644 index 0000000..a85a04a --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-mht-cet-testing-platform.md @@ -0,0 +1,1081 @@ +# MHT-CET Testing Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a reliable MHT-CET mock-test platform with verified question imports, safe rendering, timed attempts, Supabase-backed scoring, and student stats. + +**Architecture:** Supabase stores verified question sources, canonical chapters, question bodies, answer keys, attempts, responses, and result views. Next.js App Router route handlers own attempt creation, autosave, submission, and result access so answer keys never reach the browser before submission. React client components render the mock builder, timed attempt UI, and stats tables. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Supabase Postgres/Auth/RLS, Tailwind CSS 3.4, node:test, Zod, KaTeX, TanStack Table, Recharts. + +**Source Policy:** Do not seed fake MHT-CET questions. Import only local fixtures clearly marked as test data or externally supplied source files with URL/hash/license metadata. Public mocks show only questions with `verification_status = 'approved'`. + +**Git Policy:** Do not commit changes. The user handles all commits manually. + +--- + +## Phase 0: Source Governance And Product Boundaries + +**Outcome:** The app can distinguish verified, unverified, rejected, and test-only question data before any public mock exists. + +**Files:** + +- Create: `docs/MHT_CET_TESTING_PLATFORM_SOURCES.md` +- Modify: `docs/ARCHITECTURE.md` + +- [ ] **Step 1: Document official source findings** + + Add `docs/MHT_CET_TESTING_PLATFORM_SOURCES.md` with: + + ```markdown + # MHT-CET Testing Platform Sources + + ## Official Sources Checked + + - CET Cell home: https://cetcell.mahacet.org/ + - Syllabus index: https://cetcell.mahacet.org/syllabus-and-marking-scheme/ + - 2024 technical syllabus PDF: https://cetcell.mahacet.org/wp-content/uploads/2023/08/Technical_Education_CET_syllabus2024-25.pdf + - 2026 normalization PDF: https://cetcell.mahacet.org/wp-content/uploads/2023/12/MHT-CET-2026-Result-Processing-Methodology.pdf + - 2025 mock-test links PDF: https://cetcell.mahacet.org/wp-content/uploads/2023/12/Mocktest_links-3.pdf + - 2025 PCM objection notice: https://cetcell.mahacet.org/wp-content/uploads/2023/12/Notice_OT_-MHT-CET-PCM.pdf + + ## Import Rule + + Public mocks may only use questions whose source row is approved and whose imported question row is approved. + Candidate-login or time-window material must be imported only from files supplied by an authorized operator. + Test fixtures must be marked with source_type `test_fixture` and must not be mixed with production mocks. + ``` + +- [ ] **Step 2: Update architecture docs** + + Add a short section to `docs/ARCHITECTURE.md` under Data Domains: + + ```markdown + ### MHT-CET Mock Tests + + Planned testing-platform tables use Supabase-first access. Question imports are source-audited and reviewed before publication. Answer keys are stored separately from public question bodies so students cannot retrieve correct answers during active attempts. + ``` + +- [ ] **Step 3: Verify documentation formatting** + + Run: + + ```powershell + npx prettier --check docs/MHT_CET_TESTING_PLATFORM_SOURCES.md docs/ARCHITECTURE.md + ``` + + Expected: Prettier reports both markdown files are formatted. + +--- + +## Phase 1: Supabase Schema, RLS, And Seed Chapters + +**Outcome:** Database structures exist for source governance, question review, attempts, responses, and stats without answer-key leakage. + +**Files:** + +- Create: `supabase/migrations/20260425_mht_cet_testing_platform.sql` +- Create: `data/mht-cet/question-bank/chapters.json` +- Create: `lib/mht-cet/tests/schema.ts` +- Test: `lib/mht-cet/tests/schema.test.ts` + +- [ ] **Step 1: Write failing schema constant tests** + + Create `lib/mht-cet/tests/schema.test.ts`: + + ```typescript + import assert from "node:assert/strict"; + import test from "node:test"; + + import { + ATTEMPT_STATUSES, + QUESTION_STATUSES, + SUBJECTS, + SOURCE_TYPES, + } from "./schema"; + + test("MHT-CET schema constants include review and attempt states", () => { + assert.deepEqual(SUBJECTS, ["mathematics", "physics", "chemistry"]); + assert.deepEqual(QUESTION_STATUSES, [ + "draft", + "validated", + "approved", + "rejected", + "archived", + ]); + assert.deepEqual(ATTEMPT_STATUSES, [ + "in_progress", + "submitted", + "expired", + "abandoned", + ]); + assert.ok(SOURCE_TYPES.includes("official_notice")); + assert.ok(SOURCE_TYPES.includes("licensed_provider")); + assert.ok(SOURCE_TYPES.includes("test_fixture")); + }); + ``` + +- [ ] **Step 2: Run the failing schema test** + + Run: + + ```powershell + npx tsx --test lib/mht-cet/tests/schema.test.ts + ``` + + Expected: FAIL because `lib/mht-cet/tests/schema.ts` does not exist. + +- [ ] **Step 3: Create schema constants** + + Create `lib/mht-cet/tests/schema.ts`: + + ```typescript + export const SUBJECTS = ["mathematics", "physics", "chemistry"] as const; + + export const QUESTION_STATUSES = [ + "draft", + "validated", + "approved", + "rejected", + "archived", + ] as const; + + export const SOURCE_TYPES = [ + "official_notice", + "official_mock", + "candidate_export", + "licensed_provider", + "manual_entry", + "test_fixture", + ] as const; + + export const ATTEMPT_STATUSES = [ + "in_progress", + "submitted", + "expired", + "abandoned", + ] as const; + + export type MhtCetSubject = (typeof SUBJECTS)[number]; + export type MhtCetQuestionStatus = (typeof QUESTION_STATUSES)[number]; + export type MhtCetSourceType = (typeof SOURCE_TYPES)[number]; + export type MhtCetAttemptStatus = (typeof ATTEMPT_STATUSES)[number]; + ``` + +- [ ] **Step 4: Run schema test again** + + Run: + + ```powershell + npx tsx --test lib/mht-cet/tests/schema.test.ts + ``` + + Expected: PASS. + +- [ ] **Step 5: Add canonical chapter seed data** + + Create `data/mht-cet/question-bank/chapters.json` with official Std XI chapters from the extractable 2024 syllabus PDF and a reserved Std XII chapter list that must be filled from supplied official source files before public use: + + ```json + [ + { + "subject": "physics", + "standard": 11, + "slug": "motion-in-a-plane", + "name": "Motion in a plane", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "laws-of-motion", + "name": "Laws of Motion", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "gravitation", + "name": "Gravitation", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "thermal-properties-of-matter", + "name": "Thermal properties of matter", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "sound", + "name": "Sound", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "optics", + "name": "Optics", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "electrostatics", + "name": "Electrostatics", + "official": true + }, + { + "subject": "physics", + "standard": 11, + "slug": "semiconductors", + "name": "Semiconductors", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "some-basic-concepts-of-chemistry", + "name": "Some Basic concepts of chemistry", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "structure-of-atom", + "name": "Structure of atom", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "chemical-bonding", + "name": "Chemical Bonding", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "redox-reactions", + "name": "Redox reactions", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "elements-of-group-1-and-2", + "name": "Elements of group 1 and 2", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "states-of-matter", + "name": "States of Matter (Gaseous and Liquids)", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "adsorption-and-colloids", + "name": "Adsorption and colloids (Surface Chemistry)", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "hydrocarbons", + "name": "Hydrocarbons", + "official": true + }, + { + "subject": "chemistry", + "standard": 11, + "slug": "basic-principles-of-organic-chemistry", + "name": "Basic principles of organic chemistry", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "trigonometry-ii", + "name": "Trigonometry II", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "straight-line", + "name": "Straight Line", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "circle", + "name": "Circle", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "measures-of-dispersion", + "name": "Measures of Dispersion", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "probability", + "name": "Probability", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "complex-numbers", + "name": "Complex Numbers", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "permutations-and-combinations", + "name": "Permutations and Combinations", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "functions", + "name": "Functions", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "limits", + "name": "Limits", + "official": true + }, + { + "subject": "mathematics", + "standard": 11, + "slug": "continuity", + "name": "Continuity", + "official": true + } + ] + ``` + +- [ ] **Step 6: Create Supabase migration** + + Create `supabase/migrations/20260425_mht_cet_testing_platform.sql` with: + + ```sql + create extension if not exists pgcrypto; + create extension if not exists pg_trgm; + + create table if not exists public.mht_cet_question_sources ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + source_type text not null check (source_type in ('official_notice', 'official_mock', 'candidate_export', 'licensed_provider', 'manual_entry', 'test_fixture')), + title text not null, + year int check (year between 2000 and 2100), + exam_group text not null default 'pcm' check (exam_group in ('pcm', 'pcb')), + source_url text, + file_name text, + file_sha256 text, + license_note text not null, + verification_status text not null default 'draft' check (verification_status in ('draft', 'validated', 'approved', 'rejected', 'archived')), + reviewed_by uuid references auth.users(id), + reviewed_at timestamptz, + unique (file_sha256) + ); + + create table if not exists public.mht_cet_chapters ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + subject text not null check (subject in ('mathematics', 'physics', 'chemistry')), + standard int not null check (standard in (11, 12)), + slug text not null, + name text not null, + official boolean not null default false, + active boolean not null default true, + sort_order int not null default 0, + unique (subject, standard, slug) + ); + + create table if not exists public.mht_cet_questions ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + source_id uuid not null references public.mht_cet_question_sources(id) on delete restrict, + chapter_id uuid references public.mht_cet_chapters(id), + year int check (year between 2000 and 2100), + exam_group text not null default 'pcm' check (exam_group in ('pcm', 'pcb')), + subject text not null check (subject in ('mathematics', 'physics', 'chemistry')), + difficulty text not null default 'unknown' check (difficulty in ('unknown', 'easy', 'medium', 'hard')), + question_type text not null default 'single_correct' check (question_type in ('single_correct')), + marks numeric not null check (marks > 0), + negative_marks numeric not null default 0 check (negative_marks >= 0), + body jsonb not null, + body_text text not null, + body_sha256 text not null, + verification_status text not null default 'draft' check (verification_status in ('draft', 'validated', 'approved', 'rejected', 'archived')), + quality_flags text[] not null default '{}', + unique (body_sha256, source_id) + ); + + create table if not exists public.mht_cet_question_options ( + id uuid primary key default gen_random_uuid(), + question_id uuid not null references public.mht_cet_questions(id) on delete cascade, + option_order int not null check (option_order between 1 and 8), + body jsonb not null, + body_text text not null, + unique (question_id, option_order) + ); + + create table if not exists public.mht_cet_question_answers ( + question_id uuid primary key references public.mht_cet_questions(id) on delete cascade, + correct_option_ids uuid[] not null, + explanation jsonb, + explanation_text text, + updated_at timestamptz not null default now() + ); + + create table if not exists public.mht_cet_question_import_batches ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + source_id uuid not null references public.mht_cet_question_sources(id) on delete restrict, + imported_by uuid references auth.users(id), + file_name text not null, + file_sha256 text not null, + total_rows int not null default 0, + accepted_rows int not null default 0, + rejected_rows int not null default 0, + status text not null default 'validated' check (status in ('validated', 'imported', 'failed')) + ); + + create table if not exists public.mht_cet_question_import_errors ( + id uuid primary key default gen_random_uuid(), + batch_id uuid not null references public.mht_cet_question_import_batches(id) on delete cascade, + row_number int not null, + field_name text not null, + severity text not null check (severity in ('warning', 'error')), + message text not null + ); + + create table if not exists public.mht_cet_mock_attempts ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + user_id uuid not null references auth.users(id) on delete cascade, + status text not null default 'in_progress' check (status in ('in_progress', 'submitted', 'expired', 'abandoned')), + exam_group text not null default 'pcm' check (exam_group in ('pcm', 'pcb')), + duration_seconds int not null check (duration_seconds between 300 and 21600), + seed text not null, + started_at timestamptz not null default now(), + ends_at timestamptz not null, + submitted_at timestamptz, + question_count int not null default 0, + score_raw numeric not null default 0, + max_score numeric not null default 0, + correct_count int not null default 0, + wrong_count int not null default 0, + unanswered_count int not null default 0, + time_spent_seconds int not null default 0, + config jsonb not null default '{}' + ); + + create table if not exists public.mht_cet_mock_attempt_questions ( + attempt_id uuid not null references public.mht_cet_mock_attempts(id) on delete cascade, + question_id uuid not null references public.mht_cet_questions(id) on delete restrict, + position int not null, + subject text not null check (subject in ('mathematics', 'physics', 'chemistry')), + chapter_id uuid references public.mht_cet_chapters(id), + marks numeric not null, + primary key (attempt_id, question_id), + unique (attempt_id, position) + ); + + create table if not exists public.mht_cet_mock_responses ( + attempt_id uuid not null references public.mht_cet_mock_attempts(id) on delete cascade, + question_id uuid not null references public.mht_cet_questions(id) on delete restrict, + selected_option_ids uuid[] not null default '{}', + visited boolean not null default false, + marked_for_review boolean not null default false, + time_spent_seconds int not null default 0 check (time_spent_seconds >= 0), + updated_at timestamptz not null default now(), + primary key (attempt_id, question_id) + ); + + create index if not exists idx_mht_cet_questions_approved_lookup + on public.mht_cet_questions (exam_group, subject, year, verification_status); + create index if not exists idx_mht_cet_questions_chapter + on public.mht_cet_questions (chapter_id); + create index if not exists idx_mht_cet_questions_body_trgm + on public.mht_cet_questions using gin (body_text gin_trgm_ops); + create index if not exists idx_mht_cet_attempts_user + on public.mht_cet_mock_attempts (user_id, created_at desc); + + alter table public.mht_cet_question_sources enable row level security; + alter table public.mht_cet_chapters enable row level security; + alter table public.mht_cet_questions enable row level security; + alter table public.mht_cet_question_options enable row level security; + alter table public.mht_cet_question_answers enable row level security; + alter table public.mht_cet_question_import_batches enable row level security; + alter table public.mht_cet_question_import_errors enable row level security; + alter table public.mht_cet_mock_attempts enable row level security; + alter table public.mht_cet_mock_attempt_questions enable row level security; + alter table public.mht_cet_mock_responses enable row level security; + + create policy "public read active chapters" on public.mht_cet_chapters + for select using (active = true); + create policy "public read approved questions" on public.mht_cet_questions + for select using (verification_status = 'approved'); + create policy "public read approved options" on public.mht_cet_question_options + for select using ( + exists ( + select 1 from public.mht_cet_questions q + where q.id = question_id and q.verification_status = 'approved' + ) + ); + create policy "users read own attempts" on public.mht_cet_mock_attempts + for select using ((select auth.uid()) = user_id); + create policy "users insert own attempts" on public.mht_cet_mock_attempts + for insert with check ((select auth.uid()) = user_id); + create policy "users update own attempts" on public.mht_cet_mock_attempts + for update using ((select auth.uid()) = user_id); + create policy "users read own attempt questions" on public.mht_cet_mock_attempt_questions + for select using ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id and a.user_id = (select auth.uid()) + ) + ); + create policy "users read own responses" on public.mht_cet_mock_responses + for select using ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id and a.user_id = (select auth.uid()) + ) + ); + create policy "users upsert own responses" on public.mht_cet_mock_responses + for all using ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id and a.user_id = (select auth.uid()) + ) + ); + + notify pgrst, 'reload schema'; + ``` + +- [ ] **Step 7: Run lint and type checks after adding types** + + Run: + + ```powershell + npm run lint + npx tsc --noEmit + ``` + + Expected: No new lint or TypeScript errors from the schema constants. + +--- + +## Phase 2: Import Validation And Question Quality Pipeline + +**Outcome:** Operators can validate question-bank JSON before import, and invalid/unverified content is blocked. + +**Files:** + +- Create: `lib/mht-cet/questions/content-schema.ts` +- Create: `lib/mht-cet/questions/validate-question-import.ts` +- Create: `lib/mht-cet/questions/hash-question.ts` +- Create: `lib/mht-cet/questions/validate-question-import.test.ts` +- Create: `scripts/validate-mht-cet-question-bank.ts` +- Create: `scripts/import-mht-cet-question-bank.ts` +- Create: `data/mht-cet/question-bank/sample-fixture.json` + +- [ ] **Step 1: Write failing import validation tests** + + Create `lib/mht-cet/questions/validate-question-import.test.ts` with tests for: + - Valid single-correct question passes. + - Missing source metadata fails. + - Correct option ID not present in options fails. + - Raw HTML block fails. + - Duplicate option IDs fail. + - `test_fixture` rows cannot be approved for production import. + + Run: + + ```powershell + npx tsx --test lib/mht-cet/questions/validate-question-import.test.ts + ``` + + Expected: FAIL because validation code does not exist. + +- [ ] **Step 2: Implement Zod content schema** + + Create `lib/mht-cet/questions/content-schema.ts` with `QuestionBlockSchema`, `QuestionOptionSchema`, `QuestionImportRowSchema`, and exported TypeScript types. Only these block types are accepted: `paragraph`, `math`, `image`, `table`, `list`. + +- [ ] **Step 3: Implement deterministic hashing** + + Create `lib/mht-cet/questions/hash-question.ts` using Node `crypto.createHash("sha256")` over normalized question body, subject, options, and source metadata. Sort object keys before hashing so equivalent JSON hashes consistently. + +- [ ] **Step 4: Implement validator** + + Create `lib/mht-cet/questions/validate-question-import.ts` that returns: + + ```typescript + export type QuestionImportValidationResult = { + validRows: ValidatedQuestionImportRow[]; + errors: Array<{ + rowNumber: number; + fieldName: string; + severity: "warning" | "error"; + message: string; + }>; + }; + ``` + + Rules: + - Require `source.title`, `source.sourceType`, and `source.licenseNote`. + - Require `source.fileSha256` or `source.sourceUrl`. + - Require subject in `mathematics`, `physics`, `chemistry`. + - Require at least two options. + - Require exactly one correct option for `single_correct`. + - Reject raw HTML-like strings containing ` email.trim().toLowerCase()) + .filter(Boolean), + ); +} + +export async function requireMhtCetAdmin() { + const cookieStore = await cookies(); + const supabase = createClient(cookieStore); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user?.email) { + throw new MhtCetAdminError(401, "unauthorized", "Sign in as an admin."); + } + + const adminEmails = getAdminEmails(); + if (!adminEmails.has(user.email.toLowerCase())) { + throw new MhtCetAdminError( + 403, + "forbidden", + "This account is not an MHT-CET admin.", + ); + } + + return user; +} diff --git a/lib/mht-cet/mock-tests/config.test.ts b/lib/mht-cet/mock-tests/config.test.ts new file mode 100644 index 0000000..17f34b0 --- /dev/null +++ b/lib/mht-cet/mock-tests/config.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeMockConfig } from "./config"; + +test("full PCM default returns 150 questions and 10800 seconds", () => { + const config = normalizeMockConfig({ mode: "full_pcm" }); + + assert.equal(config.questionCount, 150); + assert.equal(config.durationSeconds, 10_800); + assert.deepEqual(config.subjects, ["mathematics", "physics", "chemistry"]); +}); + +test("mathematics default returns 50 questions and 5400 seconds", () => { + const config = normalizeMockConfig({ mode: "mathematics" }); + + assert.equal(config.questionCount, 50); + assert.equal(config.durationSeconds, 5_400); + assert.deepEqual(config.subjects, ["mathematics"]); +}); + +test("physics/chemistry default returns 100 questions and 5400 seconds", () => { + const config = normalizeMockConfig({ mode: "physics_chemistry" }); + + assert.equal(config.questionCount, 100); + assert.equal(config.durationSeconds, 5_400); + assert.deepEqual(config.subjects, ["physics", "chemistry"]); +}); + +test("custom duration clamps between 300 and 21600 seconds", () => { + assert.equal( + normalizeMockConfig({ mode: "custom", durationSeconds: 120 }) + .durationSeconds, + 300, + ); + assert.equal( + normalizeMockConfig({ mode: "custom", durationSeconds: 99_999 }) + .durationSeconds, + 21_600, + ); +}); + +test("empty chapter selection means all active chapters for selected subjects", () => { + const config = normalizeMockConfig({ + mode: "custom", + subjects: ["physics"], + chapterSlugs: [], + }); + + assert.deepEqual(config.chapterSlugs, []); + assert.equal(config.includeAllChapters, true); +}); diff --git a/lib/mht-cet/mock-tests/config.ts b/lib/mht-cet/mock-tests/config.ts new file mode 100644 index 0000000..c93e803 --- /dev/null +++ b/lib/mht-cet/mock-tests/config.ts @@ -0,0 +1,97 @@ +import { SUBJECTS, type MhtCetSubject } from "../schema"; + +export type MhtCetMockMode = + | "full_pcm" + | "mathematics" + | "physics_chemistry" + | "custom"; + +export type MhtCetMockConfigInput = { + mode?: MhtCetMockMode; + subjects?: MhtCetSubject[]; + chapterSlugs?: string[]; + year?: number; + examGroup?: "pcm" | "pcb"; + questionCount?: number; + durationSeconds?: number; +}; + +export type NormalizedMhtCetMockConfig = { + mode: MhtCetMockMode; + subjects: MhtCetSubject[]; + chapterSlugs: string[]; + includeAllChapters: boolean; + year?: number; + examGroup: "pcm" | "pcb"; + questionCount: number; + durationSeconds: number; +}; + +export const DEFAULT_MOCK_CONFIGS = { + full_pcm: { + subjects: ["mathematics", "physics", "chemistry"] as MhtCetSubject[], + questionCount: 150, + durationSeconds: 10_800, + }, + mathematics: { + subjects: ["mathematics"] as MhtCetSubject[], + questionCount: 50, + durationSeconds: 5_400, + }, + physics_chemistry: { + subjects: ["physics", "chemistry"] as MhtCetSubject[], + questionCount: 100, + durationSeconds: 5_400, + }, + custom: { + subjects: ["mathematics", "physics", "chemistry"] as MhtCetSubject[], + questionCount: 30, + durationSeconds: 3_600, + }, +} as const; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function normalizeSubjects( + subjects: MhtCetSubject[] | undefined, + fallback: MhtCetSubject[], +) { + const allowedSubjects = new Set(SUBJECTS); + const uniqueSubjects = [...new Set(subjects ?? [])].filter((subject) => + allowedSubjects.has(subject), + ); + + return uniqueSubjects.length > 0 ? uniqueSubjects : [...fallback]; +} + +export function normalizeMockConfig( + input: MhtCetMockConfigInput = {}, +): NormalizedMhtCetMockConfig { + const mode = input.mode ?? "full_pcm"; + const defaults = DEFAULT_MOCK_CONFIGS[mode]; + const chapterSlugs = [...new Set(input.chapterSlugs ?? [])] + .map((chapterSlug) => chapterSlug.trim()) + .filter(Boolean); + const rawQuestionCount = input.questionCount ?? defaults.questionCount; + const rawDurationSeconds = input.durationSeconds ?? defaults.durationSeconds; + const year = + input.year && input.year >= 2000 && input.year <= 2100 + ? input.year + : undefined; + + return { + mode, + subjects: normalizeSubjects( + mode === "custom" ? input.subjects : undefined, + defaults.subjects, + ), + chapterSlugs, + includeAllChapters: chapterSlugs.length === 0, + year, + examGroup: input.examGroup ?? "pcm", + questionCount: clamp(Math.trunc(rawQuestionCount), 1, 150), + durationSeconds: clamp(Math.trunc(rawDurationSeconds), 300, 21_600), + }; +} diff --git a/lib/mht-cet/mock-tests/rate-limit.ts b/lib/mht-cet/mock-tests/rate-limit.ts new file mode 100644 index 0000000..25d8b15 --- /dev/null +++ b/lib/mht-cet/mock-tests/rate-limit.ts @@ -0,0 +1,39 @@ +type RateLimitBucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); + +export function checkMockTestRateLimit({ + key, + limit, + windowMs, +}: { + key: string; + limit: number; + windowMs: number; +}) { + const now = Date.now(); + const bucket = buckets.get(key); + + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { ok: true, remaining: limit - 1, retryAfterSeconds: 0 }; + } + + if (bucket.count >= limit) { + return { + ok: false, + remaining: 0, + retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000), + }; + } + + bucket.count += 1; + return { + ok: true, + remaining: Math.max(0, limit - bucket.count), + retryAfterSeconds: 0, + }; +} diff --git a/lib/mht-cet/mock-tests/score-attempt.test.ts b/lib/mht-cet/mock-tests/score-attempt.test.ts new file mode 100644 index 0000000..b6a4c84 --- /dev/null +++ b/lib/mht-cet/mock-tests/score-attempt.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + scoreAttempt, + type ScoreAttemptAnswer, + type ScoreAttemptQuestion, +} from "./score-attempt"; + +const questions: ScoreAttemptQuestion[] = [ + { id: "q1", subject: "mathematics", chapterSlug: "functions", marks: 2 }, + { id: "q2", subject: "physics", chapterSlug: "motion-in-a-plane", marks: 1 }, + { + id: "q3", + subject: "chemistry", + chapterSlug: "structure-of-atom", + marks: 1, + }, + { id: "q4", subject: "mathematics", chapterSlug: "circle", marks: 2 }, +]; + +const answers: ScoreAttemptAnswer[] = [ + { questionId: "q1", correctOptionIds: ["a"] }, + { questionId: "q2", correctOptionIds: ["b"] }, + { questionId: "q3", correctOptionIds: ["c"] }, + { questionId: "q4", correctOptionIds: ["d"] }, +]; + +test("scores correct, wrong, and unanswered responses without negative marking", () => { + const result = scoreAttempt({ + questions, + answers, + responses: [ + { questionId: "q1", selectedOptionIds: ["a"], timeSpentSeconds: 30 }, + { questionId: "q2", selectedOptionIds: ["x"], timeSpentSeconds: 40 }, + { questionId: "q3", selectedOptionIds: [], timeSpentSeconds: 10 }, + ], + }); + + assert.equal(result.rawScore, 2); + assert.equal(result.maxScore, 6); + assert.equal(result.correctCount, 1); + assert.equal(result.wrongCount, 1); + assert.equal(result.unansweredCount, 2); + assert.equal(result.timeSpentSeconds, 80); +}); + +test("marked-for-review but unanswered still counts as unanswered", () => { + const result = scoreAttempt({ + questions: [questions[0]], + answers: [answers[0]], + responses: [ + { questionId: "q1", selectedOptionIds: [], markedForReview: true }, + ], + }); + + assert.equal(result.unansweredCount, 1); + assert.equal(result.wrongCount, 0); +}); + +test("multi-response selection is wrong for single-correct questions", () => { + const result = scoreAttempt({ + questions: [questions[0]], + answers: [answers[0]], + responses: [{ questionId: "q1", selectedOptionIds: ["a", "b"] }], + }); + + assert.equal(result.correctCount, 0); + assert.equal(result.wrongCount, 1); + assert.equal(result.rawScore, 0); +}); + +test("computes subject and chapter stats", () => { + const result = scoreAttempt({ + questions, + answers, + responses: [ + { questionId: "q1", selectedOptionIds: ["a"], timeSpentSeconds: 30 }, + { questionId: "q2", selectedOptionIds: ["b"], timeSpentSeconds: 20 }, + { questionId: "q3", selectedOptionIds: ["wrong"], timeSpentSeconds: 10 }, + { questionId: "q4", selectedOptionIds: [], timeSpentSeconds: 5 }, + ], + }); + + assert.equal(result.subjectStats.mathematics.rawScore, 2); + assert.equal(result.subjectStats.physics.correctCount, 1); + assert.equal(result.chapterStats.functions.correctCount, 1); + assert.equal(result.chapterStats["structure-of-atom"].wrongCount, 1); +}); diff --git a/lib/mht-cet/mock-tests/score-attempt.ts b/lib/mht-cet/mock-tests/score-attempt.ts new file mode 100644 index 0000000..c04c4e9 --- /dev/null +++ b/lib/mht-cet/mock-tests/score-attempt.ts @@ -0,0 +1,145 @@ +import { SUBJECTS, type MhtCetSubject } from "../schema"; + +export type ScoreAttemptQuestion = { + id: string; + subject: MhtCetSubject; + chapterSlug?: string; + marks: number; +}; + +export type ScoreAttemptAnswer = { + questionId: string; + correctOptionIds: string[]; +}; + +export type ScoreAttemptResponse = { + questionId: string; + selectedOptionIds?: string[]; + markedForReview?: boolean; + timeSpentSeconds?: number; +}; + +export type ScoreBreakdown = { + rawScore: number; + maxScore: number; + correctCount: number; + wrongCount: number; + unansweredCount: number; + timeSpentSeconds: number; + accuracy: number; +}; + +export type ScoreAttemptResult = ScoreBreakdown & { + subjectStats: Record; + chapterStats: Record; +}; + +function createEmptyBreakdown(): ScoreBreakdown { + return { + rawScore: 0, + maxScore: 0, + correctCount: 0, + wrongCount: 0, + unansweredCount: 0, + timeSpentSeconds: 0, + accuracy: 0, + }; +} + +function updateAccuracy(breakdown: ScoreBreakdown) { + const answeredCount = breakdown.correctCount + breakdown.wrongCount; + breakdown.accuracy = + answeredCount > 0 ? breakdown.correctCount / answeredCount : 0; +} + +function selectedOptionSetEquals(selected: string[], correct: string[]) { + if (selected.length !== correct.length) { + return false; + } + + const selectedSet = new Set(selected); + return correct.every((optionId) => selectedSet.has(optionId)); +} + +function recordQuestionScore({ + breakdown, + question, + selectedOptionIds, + correctOptionIds, + timeSpentSeconds, +}: { + breakdown: ScoreBreakdown; + question: ScoreAttemptQuestion; + selectedOptionIds: string[]; + correctOptionIds: string[]; + timeSpentSeconds: number; +}) { + breakdown.maxScore += question.marks; + breakdown.timeSpentSeconds += timeSpentSeconds; + + if (selectedOptionIds.length === 0) { + breakdown.unansweredCount += 1; + updateAccuracy(breakdown); + return; + } + + if (selectedOptionSetEquals(selectedOptionIds, correctOptionIds)) { + breakdown.correctCount += 1; + breakdown.rawScore += question.marks; + } else { + breakdown.wrongCount += 1; + } + + updateAccuracy(breakdown); +} + +export function scoreAttempt({ + questions, + answers, + responses, +}: { + questions: readonly ScoreAttemptQuestion[]; + answers: readonly ScoreAttemptAnswer[]; + responses: readonly ScoreAttemptResponse[]; +}): ScoreAttemptResult { + const answerByQuestionId = new Map( + answers.map((answer) => [answer.questionId, answer]), + ); + const responseByQuestionId = new Map( + responses.map((response) => [response.questionId, response]), + ); + const result: ScoreAttemptResult = { + ...createEmptyBreakdown(), + subjectStats: Object.fromEntries( + SUBJECTS.map((subject) => [subject, createEmptyBreakdown()]), + ) as Record, + chapterStats: {}, + }; + + for (const question of questions) { + const answer = answerByQuestionId.get(question.id); + const response = responseByQuestionId.get(question.id); + const selectedOptionIds = response?.selectedOptionIds ?? []; + const correctOptionIds = answer?.correctOptionIds ?? []; + const timeSpentSeconds = Math.max(0, response?.timeSpentSeconds ?? 0); + const chapterSlug = question.chapterSlug ?? "uncategorized"; + + result.chapterStats[chapterSlug] ??= createEmptyBreakdown(); + + for (const breakdown of [ + result, + result.subjectStats[question.subject], + result.chapterStats[chapterSlug], + ]) { + recordQuestionScore({ + breakdown, + question, + selectedOptionIds, + correctOptionIds, + timeSpentSeconds, + }); + } + } + + return result; +} diff --git a/lib/mht-cet/mock-tests/select-questions.test.ts b/lib/mht-cet/mock-tests/select-questions.test.ts new file mode 100644 index 0000000..4e5be30 --- /dev/null +++ b/lib/mht-cet/mock-tests/select-questions.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeMockConfig } from "./config"; +import { + selectQuestionsForMock, + type MockQuestionPoolItem, +} from "./select-questions"; + +const pool: MockQuestionPoolItem[] = [ + { + id: "m1", + subject: "mathematics", + chapterSlug: "functions", + year: 2025, + marks: 2, + verificationStatus: "approved", + }, + { + id: "m2", + subject: "mathematics", + chapterSlug: "circle", + year: 2025, + marks: 2, + verificationStatus: "approved", + }, + { + id: "p1", + subject: "physics", + chapterSlug: "motion-in-a-plane", + year: 2025, + marks: 1, + verificationStatus: "approved", + }, + { + id: "p2", + subject: "physics", + chapterSlug: "laws-of-motion", + year: 2025, + marks: 1, + verificationStatus: "approved", + }, + { + id: "c1", + subject: "chemistry", + chapterSlug: "structure-of-atom", + year: 2025, + marks: 1, + verificationStatus: "approved", + }, + { + id: "c2", + subject: "chemistry", + chapterSlug: "chemical-bonding", + year: 2024, + marks: 1, + verificationStatus: "approved", + }, + { + id: "draft", + subject: "chemistry", + chapterSlug: "chemical-bonding", + year: 2025, + marks: 1, + verificationStatus: "draft", + }, +]; + +test("selection is deterministic by seed", () => { + const config = normalizeMockConfig({ + mode: "custom", + subjects: ["mathematics", "physics"], + questionCount: 4, + }); + + const first = selectQuestionsForMock({ pool, config, seed: "same-seed" }); + const second = selectQuestionsForMock({ pool, config, seed: "same-seed" }); + + assert.equal(first.ok, true); + assert.deepEqual(first, second); +}); + +test("selection reports insufficient approved questions", () => { + const config = normalizeMockConfig({ + mode: "custom", + subjects: ["chemistry"], + year: 2025, + questionCount: 2, + }); + + const result = selectQuestionsForMock({ pool, config, seed: "low-pool" }); + + assert.equal(result.ok, false); + assert.equal(result.reason, "insufficient_questions"); + assert.equal(result.available, 1); + assert.equal(result.required, 2); +}); + +test("selection balances subjects for multi-subject custom mocks", () => { + const config = normalizeMockConfig({ + mode: "custom", + subjects: ["mathematics", "physics"], + questionCount: 4, + }); + + const result = selectQuestionsForMock({ pool, config, seed: "balance" }); + + assert.equal(result.ok, true); + assert.deepEqual( + result.questions.map((question) => question.subject).sort(), + ["mathematics", "mathematics", "physics", "physics"], + ); +}); + +test("selection keeps stable positions and does not mutate input", () => { + const originalIds = pool.map((question) => question.id); + const config = normalizeMockConfig({ + mode: "custom", + subjects: ["mathematics"], + questionCount: 2, + }); + + const result = selectQuestionsForMock({ pool, config, seed: "positions" }); + + assert.equal(result.ok, true); + assert.deepEqual( + pool.map((question) => question.id), + originalIds, + ); + assert.deepEqual( + result.questions.map((question) => question.position), + [1, 2], + ); +}); diff --git a/lib/mht-cet/mock-tests/select-questions.ts b/lib/mht-cet/mock-tests/select-questions.ts new file mode 100644 index 0000000..6ab0816 --- /dev/null +++ b/lib/mht-cet/mock-tests/select-questions.ts @@ -0,0 +1,168 @@ +import type { MhtCetQuestionStatus, MhtCetSubject } from "../schema"; +import type { NormalizedMhtCetMockConfig } from "./config"; + +export type MockQuestionPoolItem = { + id: string; + subject: MhtCetSubject; + chapterId?: string; + chapterSlug?: string; + year?: number; + marks: number; + verificationStatus: MhtCetQuestionStatus; +}; + +export type SelectedMockQuestion = MockQuestionPoolItem & { + position: number; +}; + +export type SelectQuestionsForMockResult = { + ok: boolean; + questions: SelectedMockQuestion[]; + reason?: "insufficient_questions"; + required?: number; + available?: number; +}; + +function hashSeed(seed: string) { + let hash = 2166136261; + + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; +} + +function createSeededRandom(seed: string) { + let state = hashSeed(seed) || 1; + + return () => { + state = Math.imul(1664525, state) + 1013904223; + return (state >>> 0) / 4294967296; + }; +} + +function seededShuffle(items: readonly T[], seed: string) { + const random = createSeededRandom(seed); + const shuffled = [...items]; + + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [shuffled[index], shuffled[swapIndex]] = [ + shuffled[swapIndex], + shuffled[index], + ]; + } + + return shuffled; +} + +function getSubjectTargets(config: NormalizedMhtCetMockConfig) { + if (config.mode === "full_pcm") { + return new Map([ + ["mathematics", 50], + ["physics", 50], + ["chemistry", 50], + ]); + } + + if (config.mode === "mathematics") { + return new Map([ + ["mathematics", config.questionCount], + ]); + } + + if (config.mode === "physics_chemistry") { + return new Map([ + ["physics", 50], + ["chemistry", 50], + ]); + } + + const baseCount = Math.floor(config.questionCount / config.subjects.length); + let remainder = config.questionCount % config.subjects.length; + + return new Map( + config.subjects.map((subject) => { + const count = baseCount + (remainder > 0 ? 1 : 0); + remainder -= 1; + return [subject, count] as const; + }), + ); +} + +function questionMatchesConfig( + question: MockQuestionPoolItem, + config: NormalizedMhtCetMockConfig, +) { + if (question.verificationStatus !== "approved") { + return false; + } + + if (!config.subjects.includes(question.subject)) { + return false; + } + + if (config.year && question.year !== config.year) { + return false; + } + + if ( + !config.includeAllChapters && + !config.chapterSlugs.includes(question.chapterSlug ?? "") + ) { + return false; + } + + return true; +} + +export function selectQuestionsForMock({ + pool, + config, + seed, +}: { + pool: readonly MockQuestionPoolItem[]; + config: NormalizedMhtCetMockConfig; + seed: string; +}): SelectQuestionsForMockResult { + const filteredPool = pool.filter((question) => + questionMatchesConfig(question, config), + ); + const subjectTargets = getSubjectTargets(config); + const selectedQuestions: MockQuestionPoolItem[] = []; + + for (const [subject, requiredCount] of subjectTargets) { + const subjectPool = filteredPool.filter( + (question) => question.subject === subject, + ); + + if (subjectPool.length < requiredCount) { + return { + ok: false, + questions: [], + reason: "insufficient_questions", + required: requiredCount, + available: subjectPool.length, + }; + } + + selectedQuestions.push( + ...seededShuffle(subjectPool, `${seed}:${subject}`).slice( + 0, + requiredCount, + ), + ); + } + + return { + ok: true, + questions: seededShuffle(selectedQuestions, `${seed}:final`).map( + (question, index) => ({ + ...question, + position: index + 1, + }), + ), + }; +} diff --git a/lib/mht-cet/mock-tests/supabase.ts b/lib/mht-cet/mock-tests/supabase.ts new file mode 100644 index 0000000..86138fa --- /dev/null +++ b/lib/mht-cet/mock-tests/supabase.ts @@ -0,0 +1,804 @@ +import { createAdminClient } from "@/app/lib/supabase/admin"; +import { createClient } from "@/app/lib/supabase/server"; +import { cookies } from "next/headers"; + +import { normalizeMockConfig, type NormalizedMhtCetMockConfig } from "./config"; +import { + scoreAttempt, + type ScoreAttemptAnswer, + type ScoreAttemptQuestion, +} from "./score-attempt"; +import { SUBJECTS, type MhtCetSubject } from "../schema"; +import type { + MockQuestionPoolItem, + SelectedMockQuestion, +} from "./select-questions"; + +export class MhtCetMockTestError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + ) { + super(message); + } +} + +type UnknownRecord = Record; + +type AttemptResponseInput = { + questionId: string; + selectedOptionIds?: string[]; + visited?: boolean; + markedForReview?: boolean; + timeSpentSeconds?: number; +}; + +export type MockTestAvailability = { + totalApprovedQuestions: number; + subjectCounts: Record; + yearCounts: Record; +}; + +export type MockAttemptSummary = { + id: string; + status: string; + displayStatus: "in_progress" | "submitted" | "expired" | "abandoned"; + examGroup: string; + startedAt: string; + endsAt: string; + submittedAt?: string; + questionCount: number; + scoreRaw: number; + maxScore: number; + correctCount: number; + wrongCount: number; + unansweredCount: number; + timeSpentSeconds: number; +}; + +export const EMPTY_MOCK_TEST_AVAILABILITY: MockTestAvailability = { + totalApprovedQuestions: 0, + subjectCounts: { + mathematics: 0, + physics: 0, + chemistry: 0, + }, + yearCounts: {}, +}; + +function asRecord(value: unknown): UnknownRecord { + return value && typeof value === "object" ? (value as UnknownRecord) : {}; +} + +function asString(value: unknown) { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown, fallback = 0) { + if (typeof value === "number") { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + + return fallback; +} + +function asStringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function getChapterSlug(row: UnknownRecord) { + const chapter = row.chapter; + + if (Array.isArray(chapter)) { + return asString(asRecord(chapter[0]).slug); + } + + return asString(asRecord(chapter).slug); +} + +function getChapterId(row: UnknownRecord) { + return asString(row.chapter_id) || undefined; +} + +function hasAttemptExpired(attempt: UnknownRecord) { + return Date.now() > Date.parse(asString(attempt.ends_at)); +} + +async function logAttemptEvent( + supabase: ReturnType, + event: { + attemptId: string; + userId: string; + eventType: "created" | "response_saved" | "submitted" | "expired"; + metadata?: UnknownRecord; + }, +) { + await supabase.from("mht_cet_mock_attempt_events").insert({ + attempt_id: event.attemptId, + user_id: event.userId, + event_type: event.eventType, + metadata: event.metadata ?? {}, + }); +} + +function requireString(value: unknown, message: string) { + if (typeof value !== "string" || !value) { + throw new MhtCetMockTestError(500, "invalid_database_response", message); + } + + return value; +} + +function toPoolQuestion(row: UnknownRecord): MockQuestionPoolItem { + return { + id: requireString(row.id, "Question row is missing id."), + subject: requireString( + row.subject, + "Question row is missing subject.", + ) as MockQuestionPoolItem["subject"], + chapterId: getChapterId(row), + chapterSlug: getChapterSlug(row), + year: typeof row.year === "number" ? row.year : undefined, + marks: asNumber(row.marks, 1), + verificationStatus: requireString( + row.verification_status, + "Question row is missing verification status.", + ) as MockQuestionPoolItem["verificationStatus"], + }; +} + +function hasApprovedSource(row: UnknownRecord) { + return asRecord(row.source).verification_status === "approved"; +} + +export async function getCurrentUserOrUnauthorized() { + const cookieStore = await cookies(); + const supabase = createClient(cookieStore); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + throw new MhtCetMockTestError( + 401, + "unauthorized", + "Sign in to use mock tests.", + ); + } + + return { user, supabase }; +} + +export async function loadApprovedQuestionPool( + config: NormalizedMhtCetMockConfig, +) { + const supabase = createAdminClient(); + let query = supabase + .from("mht_cet_questions") + .select( + "id, subject, chapter_id, year, marks, verification_status, chapter:mht_cet_chapters(slug), source:mht_cet_question_sources(verification_status)", + ) + .eq("verification_status", "approved") + .eq("exam_group", config.examGroup) + .in("subject", config.subjects); + + if (config.year) { + query = query.eq("year", config.year); + } + + const { data, error } = await query; + + if (error) { + throw new MhtCetMockTestError( + 500, + "question_pool_failed", + "Could not load approved questions.", + ); + } + + return ((data ?? []) as unknown[]) + .filter((row) => hasApprovedSource(asRecord(row))) + .map((row) => toPoolQuestion(asRecord(row))) + .filter( + (question) => + config.includeAllChapters || + config.chapterSlugs.includes(question.chapterSlug ?? ""), + ); +} + +export async function loadMockTestAvailability(): Promise { + const supabase = createAdminClient(); + const { data, error } = await supabase + .from("mht_cet_questions") + .select( + "id, subject, year, verification_status, source:mht_cet_question_sources(verification_status)", + ) + .eq("verification_status", "approved"); + + if (error) { + throw new MhtCetMockTestError( + 500, + "question_availability_failed", + "Could not load approved question availability.", + ); + } + + return ((data ?? []) as unknown[]) + .filter((row) => hasApprovedSource(asRecord(row))) + .reduce((availability, row) => { + const record = asRecord(row); + const subject = asString(record.subject) as MhtCetSubject; + + if (!SUBJECTS.includes(subject)) { + return availability; + } + + availability.totalApprovedQuestions += 1; + availability.subjectCounts[subject] += 1; + + if (typeof record.year === "number") { + const yearKey = String(record.year); + availability.yearCounts[yearKey] = + (availability.yearCounts[yearKey] ?? 0) + 1; + } + + return availability; + }, structuredClone(EMPTY_MOCK_TEST_AVAILABILITY)); +} + +export async function loadRecentMockAttempts( + userId: string, + limit = 8, +): Promise { + const supabase = createAdminClient(); + const { data, error } = await supabase + .from("mht_cet_mock_attempts") + .select( + "id, status, exam_group, started_at, ends_at, submitted_at, question_count, score_raw, max_score, correct_count, wrong_count, unanswered_count, time_spent_seconds", + ) + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(limit); + + if (error) { + throw new MhtCetMockTestError( + 500, + "attempt_summaries_failed", + "Could not load mock attempt summaries.", + ); + } + + return ((data ?? []) as unknown[]).map((row) => { + const record = asRecord(row); + const status = asString(record.status); + const endsAt = asString(record.ends_at); + const displayStatus = + status === "in_progress" && Date.now() > Date.parse(endsAt) + ? "expired" + : (status as MockAttemptSummary["displayStatus"]); + + return { + id: asString(record.id), + status, + displayStatus, + examGroup: asString(record.exam_group), + startedAt: asString(record.started_at), + endsAt, + submittedAt: asString(record.submitted_at) || undefined, + questionCount: asNumber(record.question_count), + scoreRaw: asNumber(record.score_raw), + maxScore: asNumber(record.max_score), + correctCount: asNumber(record.correct_count), + wrongCount: asNumber(record.wrong_count), + unansweredCount: asNumber(record.unanswered_count), + timeSpentSeconds: asNumber(record.time_spent_seconds), + }; + }); +} + +export async function createAttemptWithQuestions( + userId: string, + config: NormalizedMhtCetMockConfig, + selectedQuestions: readonly SelectedMockQuestion[], + seed: string, +) { + const supabase = createAdminClient(); + const startedAt = new Date(); + const endsAt = new Date(startedAt.getTime() + config.durationSeconds * 1000); + const maxScore = selectedQuestions.reduce( + (total, question) => total + question.marks, + 0, + ); + const { data: attempt, error: attemptError } = await supabase + .from("mht_cet_mock_attempts") + .insert({ + user_id: userId, + status: "in_progress", + exam_group: config.examGroup, + duration_seconds: config.durationSeconds, + seed, + started_at: startedAt.toISOString(), + ends_at: endsAt.toISOString(), + question_count: selectedQuestions.length, + max_score: maxScore, + config, + }) + .select("id") + .single(); + + if (attemptError) { + throw new MhtCetMockTestError( + 500, + "attempt_create_failed", + "Could not create mock attempt.", + ); + } + + const attemptId = requireString( + asRecord(attempt).id, + "Attempt insert did not return an id.", + ); + const attemptQuestions = selectedQuestions.map((question) => ({ + attempt_id: attemptId, + question_id: question.id, + position: question.position, + subject: question.subject, + chapter_id: question.chapterId ?? null, + marks: question.marks, + })); + const { error: questionsError } = await supabase + .from("mht_cet_mock_attempt_questions") + .insert(attemptQuestions); + + if (questionsError) { + await supabase.from("mht_cet_mock_attempts").delete().eq("id", attemptId); + throw new MhtCetMockTestError( + 500, + "attempt_questions_create_failed", + "Could not attach questions to mock attempt.", + ); + } + + await logAttemptEvent(supabase, { + attemptId, + userId, + eventType: "created", + metadata: { questionCount: selectedQuestions.length }, + }); + + return attemptId; +} + +export async function loadAttemptForUser(attemptId: string, userId: string) { + const supabase = createAdminClient(); + const { data: attempt, error: attemptError } = await supabase + .from("mht_cet_mock_attempts") + .select("*") + .eq("id", attemptId) + .eq("user_id", userId) + .single(); + + if (attemptError || !attempt) { + throw new MhtCetMockTestError( + 404, + "attempt_not_found", + "Mock attempt not found.", + ); + } + + const attemptRecord = asRecord(attempt); + if ( + attemptRecord.status === "in_progress" && + hasAttemptExpired(attemptRecord) + ) { + await finalizeAttempt(attemptId, userId, "expired"); + return loadAttemptForUser(attemptId, userId); + } + + const { data: questions, error: questionsError } = await supabase + .from("mht_cet_mock_attempt_questions") + .select( + "position, marks, subject, question:mht_cet_questions(id, body, body_text, subject, year, options:mht_cet_question_options(id, option_order, body, body_text))", + ) + .eq("attempt_id", attemptId) + .order("position", { ascending: true }); + + if (questionsError) { + throw new MhtCetMockTestError( + 500, + "attempt_questions_failed", + "Could not load attempt questions.", + ); + } + + const { data: responses, error: responsesError } = await supabase + .from("mht_cet_mock_responses") + .select( + "question_id, selected_option_ids, visited, marked_for_review, time_spent_seconds, updated_at", + ) + .eq("attempt_id", attemptId); + + if (responsesError) { + throw new MhtCetMockTestError( + 500, + "attempt_responses_failed", + "Could not load saved responses.", + ); + } + + return { attempt, questions: questions ?? [], responses: responses ?? [] }; +} + +export async function upsertAttemptResponse( + attemptId: string, + userId: string, + response: AttemptResponseInput, +) { + if (!response.questionId) { + throw new MhtCetMockTestError( + 422, + "invalid_response", + "Question ID is required.", + ); + } + + const supabase = createAdminClient(); + const { data: attempt, error: attemptError } = await supabase + .from("mht_cet_mock_attempts") + .select("status, ends_at") + .eq("id", attemptId) + .eq("user_id", userId) + .single(); + + if (attemptError || !attempt) { + throw new MhtCetMockTestError( + 404, + "attempt_not_found", + "Mock attempt not found.", + ); + } + + const attemptRecord = asRecord(attempt); + if (attemptRecord.status !== "in_progress") { + throw new MhtCetMockTestError( + 409, + "attempt_not_active", + "This attempt is not active.", + ); + } + + if (hasAttemptExpired(attemptRecord)) { + await finalizeAttempt(attemptId, userId, "expired"); + throw new MhtCetMockTestError( + 409, + "attempt_expired", + "This attempt has expired.", + ); + } + + const { data: membership, error: membershipError } = await supabase + .from("mht_cet_mock_attempt_questions") + .select("question_id") + .eq("attempt_id", attemptId) + .eq("question_id", response.questionId) + .single(); + + if (membershipError || !membership) { + throw new MhtCetMockTestError( + 422, + "question_not_in_attempt", + "Question is not part of this attempt.", + ); + } + + const { error } = await supabase.from("mht_cet_mock_responses").upsert({ + attempt_id: attemptId, + question_id: response.questionId, + selected_option_ids: response.selectedOptionIds ?? [], + visited: response.visited ?? true, + marked_for_review: response.markedForReview ?? false, + time_spent_seconds: Math.max(0, Math.trunc(response.timeSpentSeconds ?? 0)), + updated_at: new Date().toISOString(), + }); + + if (error) { + throw new MhtCetMockTestError( + 500, + "response_save_failed", + "Could not save response.", + ); + } + + await logAttemptEvent(supabase, { + attemptId, + userId, + eventType: "response_saved", + metadata: { questionId: response.questionId }, + }); + + return { ok: true }; +} + +async function loadScoringData(attemptId: string, userId: string) { + const supabase = createAdminClient(); + const { data: attempt, error: attemptError } = await supabase + .from("mht_cet_mock_attempts") + .select("*") + .eq("id", attemptId) + .eq("user_id", userId) + .single(); + + if (attemptError || !attempt) { + throw new MhtCetMockTestError( + 404, + "attempt_not_found", + "Mock attempt not found.", + ); + } + + const { data: attemptQuestions, error: questionsError } = await supabase + .from("mht_cet_mock_attempt_questions") + .select("question_id, subject, chapter:mht_cet_chapters(slug), marks") + .eq("attempt_id", attemptId); + + if (questionsError) { + throw new MhtCetMockTestError( + 500, + "scoring_questions_failed", + "Could not load scoring questions.", + ); + } + + const questionIds = (attemptQuestions ?? []).map((row) => + asString(asRecord(row).question_id), + ); + const { data: answers, error: answersError } = await supabase + .from("mht_cet_question_answers") + .select("question_id, correct_option_ids") + .in("question_id", questionIds); + + if (answersError) { + throw new MhtCetMockTestError( + 500, + "answer_key_failed", + "Could not load answer keys.", + ); + } + + const { data: responses, error: responsesError } = await supabase + .from("mht_cet_mock_responses") + .select( + "question_id, selected_option_ids, marked_for_review, time_spent_seconds", + ) + .eq("attempt_id", attemptId); + + if (responsesError) { + throw new MhtCetMockTestError( + 500, + "scoring_responses_failed", + "Could not load responses.", + ); + } + + const questions: ScoreAttemptQuestion[] = (attemptQuestions ?? []).map( + (row) => { + const record = asRecord(row); + return { + id: asString(record.question_id), + subject: asString(record.subject) as ScoreAttemptQuestion["subject"], + chapterSlug: getChapterSlug(record), + marks: asNumber(record.marks, 1), + }; + }, + ); + const answerRows: ScoreAttemptAnswer[] = (answers ?? []).map((row) => { + const record = asRecord(row); + return { + questionId: asString(record.question_id), + correctOptionIds: asStringArray(record.correct_option_ids), + }; + }); + const responseRows = (responses ?? []).map((row) => { + const record = asRecord(row); + return { + questionId: asString(record.question_id), + selectedOptionIds: asStringArray(record.selected_option_ids), + markedForReview: Boolean(record.marked_for_review), + timeSpentSeconds: asNumber(record.time_spent_seconds), + }; + }); + + return { + attempt: asRecord(attempt), + questions, + answers: answerRows, + responses: responseRows, + }; +} + +async function finalizeAttempt( + attemptId: string, + userId: string, + forcedStatus?: "submitted" | "expired", +) { + const supabase = createAdminClient(); + const scoringData = await loadScoringData(attemptId, userId); + + if (scoringData.attempt.status !== "in_progress") { + throw new MhtCetMockTestError( + 409, + "attempt_not_active", + "This attempt is already closed.", + ); + } + + const score = scoreAttempt(scoringData); + const now = new Date().toISOString(); + const status = + forcedStatus ?? + (hasAttemptExpired(scoringData.attempt) ? "expired" : "submitted"); + const { error } = await supabase + .from("mht_cet_mock_attempts") + .update({ + status, + submitted_at: now, + score_raw: score.rawScore, + max_score: score.maxScore, + correct_count: score.correctCount, + wrong_count: score.wrongCount, + unanswered_count: score.unansweredCount, + time_spent_seconds: score.timeSpentSeconds, + updated_at: now, + }) + .eq("id", attemptId) + .eq("user_id", userId); + + if (error) { + throw new MhtCetMockTestError( + 500, + "attempt_submit_failed", + "Could not submit attempt.", + ); + } + + await logAttemptEvent(supabase, { + attemptId, + userId, + eventType: status, + metadata: { rawScore: score.rawScore, maxScore: score.maxScore }, + }); + + return { + score, + resultUrl: `/mht-cet/mock-tests/attempts/${attemptId}/results`, + }; +} + +export async function submitAttempt(attemptId: string, userId: string) { + return finalizeAttempt(attemptId, userId); +} + +async function loadResultReview(attemptId: string) { + const supabase = createAdminClient(); + const { data: questions, error: questionsError } = await supabase + .from("mht_cet_mock_attempt_questions") + .select( + "position, question:mht_cet_questions(id, body, body_text, options:mht_cet_question_options(id, option_order, body, body_text), answer:mht_cet_question_answers(correct_option_ids, explanation, explanation_text))", + ) + .eq("attempt_id", attemptId) + .order("position", { ascending: true }); + + if (questionsError) { + throw new MhtCetMockTestError( + 500, + "result_review_failed", + "Could not load question review.", + ); + } + + const { data: responses, error: responsesError } = await supabase + .from("mht_cet_mock_responses") + .select("question_id, selected_option_ids") + .eq("attempt_id", attemptId); + + if (responsesError) { + throw new MhtCetMockTestError( + 500, + "result_responses_failed", + "Could not load result responses.", + ); + } + + const responseByQuestionId = new Map( + (responses ?? []).map((row) => { + const record = asRecord(row); + return [ + asString(record.question_id), + asStringArray(record.selected_option_ids), + ]; + }), + ); + + return (questions ?? []).map((row) => { + const record = asRecord(row); + const question = asRecord(record.question); + const answer = Array.isArray(question.answer) + ? asRecord(question.answer[0]) + : asRecord(question.answer); + const questionId = asString(question.id); + + return { + position: asNumber(record.position), + question: { + id: questionId, + body: question.body, + options: question.options ?? [], + }, + selectedOptionIds: responseByQuestionId.get(questionId) ?? [], + correctOptionIds: asStringArray(answer.correct_option_ids), + explanation: answer.explanation ?? null, + }; + }); +} + +export async function loadAttemptResults(attemptId: string, userId: string) { + let scoringData = await loadScoringData(attemptId, userId); + + if ( + asString(scoringData.attempt.status) === "in_progress" && + hasAttemptExpired(scoringData.attempt) + ) { + await finalizeAttempt(attemptId, userId, "expired"); + scoringData = await loadScoringData(attemptId, userId); + } + + if ( + !["submitted", "expired", "abandoned"].includes( + asString(scoringData.attempt.status), + ) + ) { + throw new MhtCetMockTestError( + 409, + "results_unavailable", + "Submit the attempt before viewing results.", + ); + } + + return { + attempt: scoringData.attempt, + score: scoreAttempt(scoringData), + review: await loadResultReview(attemptId), + }; +} + +export function parseAttemptResponseInput( + value: unknown, +): AttemptResponseInput { + const record = asRecord(value); + return { + questionId: asString(record.questionId), + selectedOptionIds: asStringArray(record.selectedOptionIds), + visited: typeof record.visited === "boolean" ? record.visited : undefined, + markedForReview: + typeof record.markedForReview === "boolean" + ? record.markedForReview + : undefined, + timeSpentSeconds: + typeof record.timeSpentSeconds === "number" + ? record.timeSpentSeconds + : undefined, + }; +} + +export function normalizeUnknownMockConfig(value: unknown) { + return normalizeMockConfig(asRecord(value)); +} diff --git a/lib/mht-cet/questions/content-schema.ts b/lib/mht-cet/questions/content-schema.ts new file mode 100644 index 0000000..02f607c --- /dev/null +++ b/lib/mht-cet/questions/content-schema.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +import { + QUESTION_STATUSES, + SOURCE_TYPES, + SUBJECTS, + type MhtCetQuestionStatus, + type MhtCetSourceType, + type MhtCetSubject, +} from "../schema"; + +const ExamGroupSchema = z.enum(["pcm", "pcb"]); + +export const ParagraphBlockSchema = z + .object({ + type: z.literal("paragraph"), + text: z.string(), + inlineMath: z.array(z.string()).optional(), + }) + .strict(); + +export const MathBlockSchema = z + .object({ + type: z.literal("math"), + tex: z.string(), + display: z.boolean().optional(), + }) + .strict(); + +export const ImageBlockSchema = z + .object({ + type: z.literal("image"), + src: z.string(), + alt: z.string(), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + }) + .strict(); + +export const TableBlockSchema = z + .object({ + type: z.literal("table"), + rows: z.array(z.array(z.string())).min(1), + caption: z.string().optional(), + }) + .strict(); + +export const ListBlockSchema = z + .object({ + type: z.literal("list"), + ordered: z.boolean().optional(), + items: z.array(z.string()).min(1), + }) + .strict(); + +export const QuestionBlockSchema = z.discriminatedUnion("type", [ + ParagraphBlockSchema, + MathBlockSchema, + ImageBlockSchema, + TableBlockSchema, + ListBlockSchema, +]); + +export const QuestionOptionSchema = z + .object({ + id: z.string(), + body: z.array(QuestionBlockSchema).min(1), + }) + .strict(); + +export const QuestionSourceSchema = z + .object({ + title: z.string(), + sourceType: z.enum(SOURCE_TYPES), + sourceUrl: z.string().url().optional(), + fileName: z.string().optional(), + fileSha256: z.string().optional(), + licenseNote: z.string(), + year: z.number().int().min(2000).max(2100).optional(), + examGroup: ExamGroupSchema.optional(), + }) + .strict(); + +export const QuestionImportRowSchema = z + .object({ + source: QuestionSourceSchema, + subject: z.enum(SUBJECTS), + chapterSlug: z.string().optional(), + year: z.number().int().min(2000).max(2100).optional(), + examGroup: ExamGroupSchema.optional(), + difficulty: z.enum(["unknown", "easy", "medium", "hard"]).optional(), + marks: z.number().positive().optional(), + negativeMarks: z.number().min(0).optional(), + questionType: z.literal("single_correct"), + body: z.array(QuestionBlockSchema).min(1), + options: z.array(QuestionOptionSchema).min(2), + correctOptionIds: z.array(z.string()).min(1), + explanation: z.array(QuestionBlockSchema).optional(), + verificationStatus: z.enum(QUESTION_STATUSES).optional(), + }) + .strict(); + +export type QuestionBlock = z.infer; +export type QuestionOption = z.infer; +export type QuestionSource = z.infer & { + sourceType: MhtCetSourceType; +}; +export type QuestionImportRow = z.infer & { + source: QuestionSource; + subject: MhtCetSubject; + verificationStatus?: MhtCetQuestionStatus; +}; diff --git a/lib/mht-cet/questions/hash-question.ts b/lib/mht-cet/questions/hash-question.ts new file mode 100644 index 0000000..1d05094 --- /dev/null +++ b/lib/mht-cet/questions/hash-question.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; + +import type { QuestionImportRow } from "./content-schema"; + +type JsonLike = + | null + | boolean + | number + | string + | JsonLike[] + | { [key: string]: JsonLike }; + +function normalizeForHash(value: unknown): JsonLike | undefined { + if (value === undefined) { + return undefined; + } + + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeForHash(item) ?? null); + } + + if (typeof value === "object") { + return Object.keys(value) + .sort() + .reduce>((normalized, key) => { + const item = normalizeForHash((value as Record)[key]); + + if (item !== undefined) { + normalized[key] = item; + } + + return normalized; + }, {}); + } + + return String(value); +} + +export function hashQuestionImportRow(row: QuestionImportRow) { + const normalized = normalizeForHash({ + body: row.body, + correctOptionIds: row.correctOptionIds, + examGroup: row.examGroup, + options: row.options, + source: row.source, + subject: row.subject, + year: row.year, + }); + + return createHash("sha256").update(JSON.stringify(normalized)).digest("hex"); +} diff --git a/lib/mht-cet/questions/validate-question-import.test.ts b/lib/mht-cet/questions/validate-question-import.test.ts new file mode 100644 index 0000000..d6c2cf7 --- /dev/null +++ b/lib/mht-cet/questions/validate-question-import.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import practiceRows from "../../../data/mht-cet/question-bank/practice-2026-original.json"; +import type { QuestionImportRow } from "./content-schema"; +import { validateQuestionImportRows } from "./validate-question-import"; + +const validRow: QuestionImportRow = { + source: { + title: "Local fixture batch", + sourceType: "licensed_provider", + licenseNote: "Operator supplied licensed sample for validation tests.", + fileSha256: + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + year: 2025, + examGroup: "pcm", + }, + subject: "mathematics", + chapterSlug: "functions", + year: 2025, + examGroup: "pcm", + marks: 2, + negativeMarks: 0, + questionType: "single_correct", + body: [{ type: "paragraph", text: "What is f(2) when f(x) = x + 3?" }], + options: [ + { id: "a", body: [{ type: "paragraph", text: "4" }] }, + { id: "b", body: [{ type: "paragraph", text: "5" }] }, + { id: "c", body: [{ type: "paragraph", text: "6" }] }, + { id: "d", body: [{ type: "paragraph", text: "7" }] }, + ], + correctOptionIds: ["b"], + explanation: [{ type: "paragraph", text: "Substitute x = 2." }], +}; + +function cloneValidRow(overrides: Partial = {}) { + return { + ...structuredClone(validRow), + ...overrides, + } satisfies QuestionImportRow; +} + +test("valid single-correct question passes", () => { + const result = validateQuestionImportRows([validRow], { mode: "production" }); + + assert.equal( + result.errors.filter((error) => error.severity === "error").length, + 0, + ); + assert.equal(result.validRows.length, 1); + assert.equal(result.validRows[0]?.bodySha256.length, 64); +}); + +test("missing source metadata fails", () => { + const row = cloneValidRow({ + source: { + ...validRow.source, + title: "", + licenseNote: "", + fileSha256: undefined, + sourceUrl: undefined, + }, + }); + + const result = validateQuestionImportRows([row], { mode: "production" }); + + assert.ok(result.errors.some((error) => error.fieldName === "source.title")); + assert.ok( + result.errors.some((error) => error.fieldName === "source.licenseNote"), + ); + assert.ok(result.errors.some((error) => error.fieldName === "source")); + assert.equal(result.validRows.length, 0); +}); + +test("correct option ID not present in options fails", () => { + const result = validateQuestionImportRows( + [cloneValidRow({ correctOptionIds: ["missing"] })], + { mode: "production" }, + ); + + assert.ok( + result.errors.some((error) => error.fieldName === "correctOptionIds"), + ); + assert.equal(result.validRows.length, 0); +}); + +test("raw HTML-like content fails", () => { + const result = validateQuestionImportRows( + [ + cloneValidRow({ + body: [{ type: "paragraph", text: "" }], + }), + ], + { mode: "production" }, + ); + + assert.ok(result.errors.some((error) => error.fieldName === "body")); + assert.equal(result.validRows.length, 0); +}); + +test("duplicate option IDs fail", () => { + const row = cloneValidRow({ + options: [ + { id: "same", body: [{ type: "paragraph", text: "4" }] }, + { id: "same", body: [{ type: "paragraph", text: "5" }] }, + ], + correctOptionIds: ["same"], + }); + + const result = validateQuestionImportRows([row], { mode: "production" }); + + assert.ok(result.errors.some((error) => error.fieldName === "options")); + assert.equal(result.validRows.length, 0); +}); + +test("approved test fixtures cannot be imported in production", () => { + const row = cloneValidRow({ + source: { + ...validRow.source, + sourceType: "test_fixture", + licenseNote: + "Local test fixture for renderer and scoring development; not official MHT-CET content.", + }, + verificationStatus: "approved", + }); + + const result = validateQuestionImportRows([row], { mode: "production" }); + + assert.ok( + result.errors.some((error) => error.fieldName === "source.sourceType"), + ); + assert.equal(result.validRows.length, 0); +}); + +test("original practice bank validates for production import", () => { + const result = validateQuestionImportRows(practiceRows, { + mode: "production", + }); + + assert.equal( + result.errors.filter((error) => error.severity === "error").length, + 0, + ); + assert.equal(result.validRows.length, practiceRows.length); + assert.equal(practiceRows.length, 30); +}); diff --git a/lib/mht-cet/questions/validate-question-import.ts b/lib/mht-cet/questions/validate-question-import.ts new file mode 100644 index 0000000..f558ddd --- /dev/null +++ b/lib/mht-cet/questions/validate-question-import.ts @@ -0,0 +1,235 @@ +import { + QuestionImportRowSchema, + type QuestionImportRow, +} from "./content-schema"; +import { hashQuestionImportRow } from "./hash-question"; + +export type QuestionImportValidationMode = + | "development" + | "test" + | "production"; + +export type QuestionImportValidationError = { + rowNumber: number; + fieldName: string; + severity: "warning" | "error"; + message: string; +}; + +export type ValidatedQuestionImportRow = QuestionImportRow & { + bodySha256: string; +}; + +export type QuestionImportValidationResult = { + validRows: ValidatedQuestionImportRow[]; + errors: QuestionImportValidationError[]; +}; + +type ValidateQuestionImportRowsOptions = { + mode?: QuestionImportValidationMode; +}; + +const UNSAFE_TEXT_PATTERN = / hasUnsafeText(item)); + } + + if (value && typeof value === "object") { + return Object.values(value).some((item) => hasUnsafeText(item)); + } + + return false; +} + +function pathToFieldName(path: Array) { + return path.length > 0 ? path.join(".") : "row"; +} + +function pushIssue( + errors: QuestionImportValidationError[], + rowNumber: number, + fieldName: string, + severity: "warning" | "error", + message: string, +) { + errors.push({ rowNumber, fieldName, severity, message }); +} + +export function validateQuestionImportRows( + rows: readonly unknown[], + options: ValidateQuestionImportRowsOptions = {}, +): QuestionImportValidationResult { + const mode = options.mode ?? "development"; + const validRows: ValidatedQuestionImportRow[] = []; + const errors: QuestionImportValidationError[] = []; + + rows.forEach((rawRow, rowIndex) => { + const rowNumber = rowIndex + 1; + const parsed = QuestionImportRowSchema.safeParse(rawRow); + + if (!parsed.success) { + for (const issue of parsed.error.issues) { + pushIssue( + errors, + rowNumber, + pathToFieldName(issue.path), + "error", + issue.message, + ); + } + + return; + } + + const row = parsed.data; + const rowErrorsBeforeCustomValidation = errors.length; + const sourceTitle = row.source.title.trim(); + const licenseNote = row.source.licenseNote.trim(); + const optionIds = row.options.map((option) => option.id.trim()); + const uniqueOptionIds = new Set(optionIds); + + if (!sourceTitle) { + pushIssue( + errors, + rowNumber, + "source.title", + "error", + "Source title is required.", + ); + } + + if (!licenseNote) { + pushIssue( + errors, + rowNumber, + "source.licenseNote", + "error", + "Source license note is required.", + ); + } + + if (!row.source.fileSha256 && !row.source.sourceUrl) { + pushIssue( + errors, + rowNumber, + "source", + "error", + "Provide either a source URL or source file SHA-256.", + ); + } + + if (row.source.sourceType === "test_fixture" && mode === "production") { + pushIssue( + errors, + rowNumber, + "source.sourceType", + "error", + "Test fixture rows cannot be imported in production.", + ); + } + + if ( + row.questionType === "single_correct" && + row.correctOptionIds.length !== 1 + ) { + pushIssue( + errors, + rowNumber, + "correctOptionIds", + "error", + "Single-correct questions must have exactly one correct option ID.", + ); + } + + if (uniqueOptionIds.size !== optionIds.length) { + pushIssue( + errors, + rowNumber, + "options", + "error", + "Option IDs must be unique.", + ); + } + + for (const correctOptionId of row.correctOptionIds) { + if (!uniqueOptionIds.has(correctOptionId)) { + pushIssue( + errors, + rowNumber, + "correctOptionIds", + "error", + `Correct option ID '${correctOptionId}' does not exist in options.`, + ); + } + } + + if (hasUnsafeText(row.body)) { + pushIssue( + errors, + rowNumber, + "body", + "error", + "Question body contains unsafe HTML-like text.", + ); + } + + if (hasUnsafeText(row.options)) { + pushIssue( + errors, + rowNumber, + "options", + "error", + "Options contain unsafe HTML-like text.", + ); + } + + if (hasUnsafeText(row.explanation)) { + pushIssue( + errors, + rowNumber, + "explanation", + "error", + "Explanation contains unsafe HTML-like text.", + ); + } + + if (!row.explanation || row.explanation.length === 0) { + pushIssue( + errors, + rowNumber, + "explanation", + "warning", + "Explanation is missing.", + ); + } + + if (!row.chapterSlug?.trim()) { + pushIssue( + errors, + rowNumber, + "chapterSlug", + "warning", + "Chapter slug is missing.", + ); + } + + const customErrorsForRow = errors + .slice(rowErrorsBeforeCustomValidation) + .some((issue) => issue.severity === "error"); + + if (!customErrorsForRow) { + validRows.push({ + ...row, + bodySha256: hashQuestionImportRow(row), + }); + } + }); + + return { validRows, errors }; +} diff --git a/lib/mht-cet/schema.ts b/lib/mht-cet/schema.ts new file mode 100644 index 0000000..3b88c4d --- /dev/null +++ b/lib/mht-cet/schema.ts @@ -0,0 +1,30 @@ +export const SUBJECTS = ["mathematics", "physics", "chemistry"] as const; + +export const QUESTION_STATUSES = [ + "draft", + "validated", + "approved", + "rejected", + "archived", +] as const; + +export const SOURCE_TYPES = [ + "official_notice", + "official_mock", + "candidate_export", + "licensed_provider", + "manual_entry", + "test_fixture", +] as const; + +export const ATTEMPT_STATUSES = [ + "in_progress", + "submitted", + "expired", + "abandoned", +] as const; + +export type MhtCetSubject = (typeof SUBJECTS)[number]; +export type MhtCetQuestionStatus = (typeof QUESTION_STATUSES)[number]; +export type MhtCetSourceType = (typeof SOURCE_TYPES)[number]; +export type MhtCetAttemptStatus = (typeof ATTEMPT_STATUSES)[number]; diff --git a/lib/mht-cet/tests/schema.test.ts b/lib/mht-cet/tests/schema.test.ts new file mode 100644 index 0000000..a2add2d --- /dev/null +++ b/lib/mht-cet/tests/schema.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + ATTEMPT_STATUSES, + QUESTION_STATUSES, + SOURCE_TYPES, + SUBJECTS, +} from "./schema"; + +test("MHT-CET schema constants include review and attempt states", () => { + assert.deepEqual(SUBJECTS, ["mathematics", "physics", "chemistry"]); + assert.deepEqual(QUESTION_STATUSES, [ + "draft", + "validated", + "approved", + "rejected", + "archived", + ]); + assert.deepEqual(ATTEMPT_STATUSES, [ + "in_progress", + "submitted", + "expired", + "abandoned", + ]); + assert.ok(SOURCE_TYPES.includes("official_notice")); + assert.ok(SOURCE_TYPES.includes("licensed_provider")); + assert.ok(SOURCE_TYPES.includes("test_fixture")); +}); + +test("MHT-CET migration protects answers and response membership", () => { + const migrationSql = readFileSync( + new URL( + "../../../supabase/migrations/20260425_mht_cet_testing_platform.sql", + import.meta.url, + ), + "utf8", + ); + + assert.match( + migrationSql, + /alter table public\.mht_cet_question_answers enable row level security;/, + ); + assert.doesNotMatch( + migrationSql, + /create policy [\s\S]+ on public\.mht_cet_question_answers/, + ); + assert.match( + migrationSql, + /foreign key \(attempt_id, question_id\)\s+references public\.mht_cet_mock_attempt_questions\(attempt_id, question_id\)/, + ); + assert.match( + migrationSql, + /create policy "users upsert own responses"[\s\S]+for all[\s\S]+using \([\s\S]+\)\s+with check \(/, + ); + assert.match( + migrationSql, + /insert into public\.mht_cet_chapters \(subject, standard, slug, name, official, active, sort_order\)/, + ); + assert.match( + migrationSql, + /where s\.id = source_id and s\.verification_status = 'approved'/, + ); +}); diff --git a/lib/mht-cet/tests/schema.ts b/lib/mht-cet/tests/schema.ts new file mode 100644 index 0000000..2f37b99 --- /dev/null +++ b/lib/mht-cet/tests/schema.ts @@ -0,0 +1 @@ +export * from "../schema"; diff --git a/next.config.mjs b/next.config.mjs index dfd7d46..e2806c0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,8 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", @@ -6,7 +11,9 @@ const nextConfig = { reactCompiler: true, // Enable React Compiler for automatic memoization // Turbopack configuration - turbopack: {}, + turbopack: { + root: projectRoot, + }, // Production performance optimizations poweredByHeader: false, // Remove X-Powered-By header for security diff --git a/package-lock.json b/package-lock.json index 7a1b4d4..853891c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@faker-js/faker": "^8.4.1", - "@next/third-parties": "^16.1.4", + "@next/third-parties": "^16.2.4", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", @@ -36,6 +36,7 @@ "@tailwindcss/typography": "^0.5.13", "@tanstack/react-table": "^8.17.3", "@tanstack/react-virtual": "^3.13.13", + "@types/katex": "^0.16.8", "@vercel/og": "^0.6.2", "@xyflow/react": "^12.8.1", "class-variance-authority": "^0.7.0", @@ -47,11 +48,11 @@ "fuzzysort": "^3.1.0", "gsap": "^3.12.5", "input-otp": "^1.4.2", + "katex": "^0.16.45", "lucide-react": "^0.563.0", - "next": "^16.1.4", + "next": "^16.2.4", "nextjs-toploader": "^1.6.12", "nuqs": "^2.8.5", - "pocketbase": "^0.25.2", "react": "^19.2.3", "react-dom": "^19.2.3", "react-fast-marquee": "^1.6.4", @@ -69,7 +70,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@next/eslint-plugin-next": "^16.1.4", + "@next/eslint-plugin-next": "^16.2.4", "@types/csv-parse": "^1.1.12", "@types/node": "^20", "@types/react": "^19.2.9", @@ -77,7 +78,7 @@ "babel-plugin-react-compiler": "^1.0.0", "dotenv-cli": "^8.0.0", "eslint": "^9.39.2", - "eslint-config-next": "^16.1.4", + "eslint-config-next": "^16.2.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "postcss": "^8", @@ -1715,15 +1716,15 @@ } }, "node_modules/@next/env": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.4.tgz", - "integrity": "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.4.tgz", - "integrity": "sha512-38WMjGP8y+1MN4bcZFs+GTcBe0iem5GGTzFE5GWW/dWdRKde7LOXH3lQT2QuoquVWyfl2S0fQRchGmeacGZ4Wg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", "dev": true, "license": "MIT", "dependencies": { @@ -1731,9 +1732,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.4.tgz", - "integrity": "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -1747,9 +1748,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.4.tgz", - "integrity": "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -1763,9 +1764,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.4.tgz", - "integrity": "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], @@ -1779,9 +1780,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.4.tgz", - "integrity": "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], @@ -1795,9 +1796,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.4.tgz", - "integrity": "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], @@ -1811,9 +1812,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.4.tgz", - "integrity": "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], @@ -1827,9 +1828,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.4.tgz", - "integrity": "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -1843,9 +1844,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz", - "integrity": "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -1859,9 +1860,9 @@ } }, "node_modules/@next/third-parties": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/third-parties/-/third-parties-16.1.4.tgz", - "integrity": "sha512-mucTu6xE1jlx0cDVVoBmbHjAUDrgh35kLa3zbGO/GD9Uv5wsVKqDBlXLyMwXAg9rbGaloUhSMeFUSD+GV0jU9w==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/third-parties/-/third-parties-16.2.4.tgz", + "integrity": "sha512-FhDDX02cAr0WIo3la+QHP3XaAAV6twCfFk/y8pHikFT8MHwNpB3XgEdaT0omLrIWBORhM5wkbbUJFq+pBqZzmw==", "license": "MIT", "dependencies": { "third-party-capital": "1.0.20" @@ -4031,6 +4032,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", @@ -5047,12 +5054,15 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", - "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", + "version": "2.10.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.22.tgz", + "integrity": "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bin-links": { @@ -6255,13 +6265,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.4.tgz", - "integrity": "sha512-iCrrNolUPpn/ythx0HcyNRfUBgTkaNBXByisKUbusPGCl8DMkDXXAu7exlSTSLGTIsH9lFE/c4s/3Qiyv2qwdA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.4", + "@next/eslint-plugin-next": "16.2.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -7907,6 +7917,31 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8221,14 +8256,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.4.tgz", - "integrity": "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.1.4", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -8240,15 +8275,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.4", - "@next/swc-darwin-x64": "16.1.4", - "@next/swc-linux-arm64-gnu": "16.1.4", - "@next/swc-linux-arm64-musl": "16.1.4", - "@next/swc-linux-x64-gnu": "16.1.4", - "@next/swc-linux-x64-musl": "16.1.4", - "@next/swc-win32-arm64-msvc": "16.1.4", - "@next/swc-win32-x64-msvc": "16.1.4", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8748,12 +8783,6 @@ "node": ">= 6" } }, - "node_modules/pocketbase": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.25.2.tgz", - "integrity": "sha512-ONZl1+qHJMnhR2uacBlBJ90lm7njtL/zy0606+1ROfK9hSL4LRBRc8r89rMcNRzPzRqCNyoFTh2Qg/lYXdEC1w==", - "license": "MIT" - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index aa2aa0c..c2d676a 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,13 @@ "diagnose-pocketbase": "npx tsx scripts/diagnostic-pocketbase.ts", "upload-all-india-cutoffs": "npx tsx scripts/batch-upload-all-india-rounds.ts", "import-josaa": "npx tsx scripts/batch-import-josaa.ts", - "migrate:pocketbase-to-supabase": "npx tsx scripts/migrate-pocketbase-to-supabase.ts" + "migrate:pocketbase-to-supabase": "npx tsx scripts/migrate-pocketbase-to-supabase.ts", + "seed:mht-cet-fixtures": "npx tsx scripts/seed-mht-cet-sample-question-bank.ts", + "seed:mht-cet-practice": "npx tsx scripts/seed-mht-cet-sample-question-bank.ts" }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@next/third-parties": "^16.1.4", + "@next/third-parties": "^16.2.4", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", @@ -54,6 +56,7 @@ "@tailwindcss/typography": "^0.5.13", "@tanstack/react-table": "^8.17.3", "@tanstack/react-virtual": "^3.13.13", + "@types/katex": "^0.16.8", "@vercel/og": "^0.6.2", "@xyflow/react": "^12.8.1", "class-variance-authority": "^0.7.0", @@ -65,8 +68,9 @@ "fuzzysort": "^3.1.0", "gsap": "^3.12.5", "input-otp": "^1.4.2", + "katex": "^0.16.45", "lucide-react": "^0.563.0", - "next": "^16.1.4", + "next": "^16.2.4", "nextjs-toploader": "^1.6.12", "nuqs": "^2.8.5", "react": "^19.2.3", @@ -86,7 +90,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@next/eslint-plugin-next": "^16.1.4", + "@next/eslint-plugin-next": "^16.2.4", "@types/csv-parse": "^1.1.12", "@types/node": "^20", "@types/react": "^19.2.9", @@ -94,7 +98,7 @@ "babel-plugin-react-compiler": "^1.0.0", "dotenv-cli": "^8.0.0", "eslint": "^9.39.2", - "eslint-config-next": "^16.1.4", + "eslint-config-next": "^16.2.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "postcss": "^8", diff --git a/scripts/import-mht-cet-question-bank.ts b/scripts/import-mht-cet-question-bank.ts new file mode 100644 index 0000000..9f3529e --- /dev/null +++ b/scripts/import-mht-cet-question-bank.ts @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; + +import { validateQuestionImportRows } from "../lib/mht-cet/questions/validate-question-import"; + +const filePath = process.argv[2]; + +if (!filePath) { + console.error( + "Usage: npx tsx scripts/import-mht-cet-question-bank.ts ", + ); + process.exit(1); +} + +const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; +const rows = Array.isArray(parsed) + ? parsed + : parsed && + typeof parsed === "object" && + Array.isArray((parsed as { questions?: unknown }).questions) + ? (parsed as { questions: unknown[] }).questions + : null; + +if (!rows) { + console.error( + "Question bank JSON must be an array or an object with a questions array.", + ); + process.exit(1); +} + +const result = validateQuestionImportRows(rows, { mode: "production" }); +const errorCount = result.errors.filter( + (error) => error.severity === "error", +).length; + +if (errorCount > 0) { + console.error(`Import blocked: ${errorCount} validation errors found.`); + for (const issue of result.errors) { + console.error( + `${issue.severity.toUpperCase()} row ${issue.rowNumber} ${issue.fieldName}: ${issue.message}`, + ); + } + process.exit(1); +} + +console.log(`Validated ${result.validRows.length} production-ready rows.`); +console.log( + "Supabase insertion is handled by the admin import API route in the application.", +); diff --git a/scripts/seed-mht-cet-sample-question-bank.ts b/scripts/seed-mht-cet-sample-question-bank.ts new file mode 100644 index 0000000..25c0b75 --- /dev/null +++ b/scripts/seed-mht-cet-sample-question-bank.ts @@ -0,0 +1,242 @@ +import { createClient } from "@supabase/supabase-js"; +import * as dotenv from "dotenv"; + +import practiceRows from "../data/mht-cet/question-bank/practice-2026-original.json"; +import sampleRows from "../data/mht-cet/question-bank/sample-fixture.json"; +import { + validateQuestionImportRows, + type ValidatedQuestionImportRow, +} from "../lib/mht-cet/questions/validate-question-import"; + +dotenv.config({ path: ".env.local" }); +dotenv.config({ path: ".env" }); + +function blocksToText(blocks: unknown) { + return JSON.stringify(blocks) + .replace(/[{}\[\]":,]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !serviceRoleKey) { + console.error( + "Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY.", + ); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +const seedRows = [...sampleRows, ...practiceRows]; +const validation = validateQuestionImportRows(seedRows, { + mode: "development", +}); +const errorCount = validation.errors.filter( + (error) => error.severity === "error", +).length; + +if (errorCount > 0) { + console.error(`Question bank validation failed with ${errorCount} errors.`); + for (const issue of validation.errors) { + console.error( + `${issue.severity.toUpperCase()} row ${issue.rowNumber} ${issue.fieldName}: ${issue.message}`, + ); + } + process.exit(1); +} + +async function upsertSource(row: ValidatedQuestionImportRow) { + const sourcePayload = { + source_type: row.source.sourceType, + title: row.source.title, + year: row.source.year ?? row.year, + exam_group: row.source.examGroup ?? row.examGroup ?? "pcm", + source_url: row.source.sourceUrl, + file_name: row.source.fileName, + file_sha256: row.source.fileSha256, + license_note: row.source.licenseNote, + verification_status: "approved", + reviewed_at: new Date().toISOString(), + }; + + if (row.source.fileSha256) { + const { data: source, error: sourceError } = await supabase + .from("mht_cet_question_sources") + .upsert(sourcePayload, { onConflict: "file_sha256" }) + .select("id") + .single(); + + if (sourceError || !source) { + throw new Error(sourceError?.message ?? "Could not upsert source."); + } + + return (source as { id: string }).id; + } + + if (!row.source.sourceUrl) { + throw new Error("Source URL or file SHA-256 is required."); + } + + const { data: existingSource, error: lookupError } = await supabase + .from("mht_cet_question_sources") + .select("id") + .eq("source_url", row.source.sourceUrl) + .eq("title", row.source.title) + .eq("year", sourcePayload.year) + .eq("exam_group", sourcePayload.exam_group) + .limit(1) + .maybeSingle(); + + if (lookupError) { + throw new Error(lookupError.message); + } + + if (existingSource) { + const { data: source, error: sourceError } = await supabase + .from("mht_cet_question_sources") + .update(sourcePayload) + .eq("id", (existingSource as { id: string }).id) + .select("id") + .single(); + + if (sourceError || !source) { + throw new Error(sourceError?.message ?? "Could not update source."); + } + + return (source as { id: string }).id; + } + + const { data: source, error: sourceError } = await supabase + .from("mht_cet_question_sources") + .insert(sourcePayload) + .select("id") + .single(); + + if (sourceError || !source) { + throw new Error(sourceError?.message ?? "Could not insert source."); + } + + return (source as { id: string }).id; +} + +async function seed() { + let seededQuestions = 0; + + for (const row of validation.validRows) { + const sourceId = await upsertSource(row); + + const { data: chapter, error: chapterError } = await supabase + .from("mht_cet_chapters") + .select("id") + .eq("subject", row.subject) + .eq("slug", row.chapterSlug ?? "") + .maybeSingle(); + + if (chapterError) { + throw new Error(chapterError.message); + } + + const { data: question, error: questionError } = await supabase + .from("mht_cet_questions") + .upsert( + { + source_id: sourceId, + chapter_id: (chapter as { id?: string } | null)?.id, + year: row.year, + exam_group: row.examGroup ?? "pcm", + subject: row.subject, + difficulty: row.difficulty ?? "unknown", + question_type: row.questionType, + marks: row.marks ?? (row.subject === "mathematics" ? 2 : 1), + negative_marks: row.negativeMarks ?? 0, + body: row.body, + body_text: blocksToText(row.body), + body_sha256: row.bodySha256, + verification_status: "approved", + }, + { onConflict: "body_sha256,source_id" }, + ) + .select("id") + .single(); + + if (questionError || !question) { + throw new Error(questionError?.message ?? "Could not upsert question."); + } + + const questionId = (question as { id: string }).id; + const { data: options, error: optionsError } = await supabase + .from("mht_cet_question_options") + .upsert( + row.options.map((option, optionIndex) => ({ + question_id: questionId, + option_order: optionIndex + 1, + body: option.body, + body_text: blocksToText(option.body), + })), + { onConflict: "question_id,option_order" }, + ) + .select("id, option_order"); + + if (optionsError || !options) { + throw new Error(optionsError?.message ?? "Could not upsert options."); + } + + const optionByImportId = new Map(); + for (const [optionIndex, option] of row.options.entries()) { + const insertedOption = ( + options as Array<{ id: string; option_order: number }> + ).find((item) => item.option_order === optionIndex + 1); + + if (!insertedOption) { + throw new Error("Could not map saved options."); + } + + optionByImportId.set(option.id, insertedOption.id); + } + + const correctOptionIds = row.correctOptionIds.map((optionId) => { + const savedOptionId = optionByImportId.get(optionId); + + if (!savedOptionId) { + throw new Error(`Could not map correct option ${optionId}.`); + } + + return savedOptionId; + }); + + const { error: answerError } = await supabase + .from("mht_cet_question_answers") + .upsert( + { + question_id: questionId, + correct_option_ids: correctOptionIds, + explanation: row.explanation, + explanation_text: row.explanation + ? blocksToText(row.explanation) + : null, + updated_at: new Date().toISOString(), + }, + { onConflict: "question_id" }, + ); + + if (answerError) { + throw new Error(answerError.message); + } + + seededQuestions += 1; + } + + console.log( + `Seeded ${seededQuestions} approved MHT-CET practice questions for mock testing.`, + ); +} + +seed().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/upload-mht-cet-2025-rounds-2-4.ts b/scripts/upload-mht-cet-2025-rounds-2-4.ts new file mode 100644 index 0000000..4561614 --- /dev/null +++ b/scripts/upload-mht-cet-2025-rounds-2-4.ts @@ -0,0 +1,361 @@ +/** + * Upload MHT-CET CAP 2025 cutoff data for rounds 2, 3 and 4 into Supabase. + * + * Source: handoff/mht-cet-cap-2025-rounds-2-4/data/round-{2,3,4}/combined_cutoffs_with_status.csv + * Target tables (must already exist; see supabase/migrations/20260423_mht_cet_2025_rounds_2_3_4.sql): + * public."2025_mht_cet_round_two_cutoffs" + * public."2025_mht_cet_round_three_cutoffs" + * public."2025_mht_cet_round_four_cutoffs" + * + * Behaviour: + * - Truncates the target table before insert (idempotent re-runs). + * - Uses Supabase service role; bypasses RLS. + * - Inserts in chunks of 1000 with limited concurrency. + * - Generates 15-char base32 IDs (PocketBase-compatible) using crypto.randomBytes. + * + * Run: npx tsx scripts/upload-mht-cet-2025-rounds-2-4.ts [--rounds=2,3,4] [--dry-run] + */ +import { createClient } from "@supabase/supabase-js"; +import { createReadStream } from "fs"; +import { parse } from "csv-parse"; +import * as path from "path"; +import * as crypto from "crypto"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; +const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; +if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + throw new Error( + "Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY", + ); +} + +const SUPABASE_URL_VALUE = SUPABASE_URL; +const SUPABASE_SERVICE_ROLE_KEY_VALUE = SUPABASE_SERVICE_ROLE_KEY; + +const supabase = createClient( + SUPABASE_URL_VALUE, + SUPABASE_SERVICE_ROLE_KEY_VALUE, + { + auth: { autoRefreshToken: false, persistSession: false }, + }, +); + +const ROUND_TABLES: Record = { + 2: "2025_mht_cet_round_two_cutoffs", + 3: "2025_mht_cet_round_three_cutoffs", + 4: "2025_mht_cet_round_four_cutoffs", +}; + +const EXPECTED_ROW_COUNTS: Record = { + 2: 46257, + 3: 46632, + 4: 47060, +}; + +const HANDOFF_ROOT = path.join( + __dirname, + "..", + "handoff", + "mht-cet-cap-2025-rounds-2-4", + "data", +); + +const CHUNK_SIZE = 1000; +const CONCURRENCY = 4; + +// PocketBase-style 15-char id from base32 alphabet (lowercase, no padding). +const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; +function generateId(): string { + const buf = crypto.randomBytes(15); + let out = ""; + for (let i = 0; i < 15; i += 1) { + out += ID_ALPHABET[buf[i] % ID_ALPHABET.length]; + } + return out; +} + +interface CsvRow { + college_code: string; + college_name: string; + course_code: string; + course_name: string; + category: string; + seat_allocation_section: string; + cutoff_score: string; + last_rank: string; + total_admitted: string; + Status: string; + "Home University": string; +} + +interface InsertRow { + id: string; + college_code: string | null; + college_name: string | null; + course_code: string | null; + course_name: string | null; + category: string | null; + seat_allocation_section: string | null; + cutoff_score: number | null; + last_rank: number | null; + total_admitted: number | null; + status: string | null; + home_university: string | null; +} + +type OpenApiSpec = { + paths?: Record; +}; + +function emptyToNull(value: string | undefined | null): string | null { + if (value === undefined || value === null) return null; + const trimmed = String(value).trim(); + return trimmed === "" ? null : trimmed; +} + +function parseNumeric(value: string | undefined | null): number | null { + const t = emptyToNull(value); + if (t === null) return null; + const n = Number(t); + return Number.isFinite(n) ? n : null; +} + +function parseInteger(value: string | undefined | null): number | null { + const t = emptyToNull(value); + if (t === null) return null; + const n = Number(t); + if (!Number.isFinite(n)) return null; + return Math.trunc(n); +} + +function mapRow(row: CsvRow): InsertRow { + return { + id: generateId(), + college_code: emptyToNull(row.college_code), + college_name: emptyToNull(row.college_name), + course_code: emptyToNull(row.course_code), + course_name: emptyToNull(row.course_name), + category: emptyToNull(row.category), + seat_allocation_section: emptyToNull(row.seat_allocation_section), + cutoff_score: parseNumeric(row.cutoff_score), + last_rank: parseInteger(row.last_rank), + total_admitted: parseInteger(row.total_admitted), + status: emptyToNull(row.Status), + home_university: emptyToNull(row["Home University"]), + }; +} + +async function fetchExposedRestPaths(): Promise> { + const headers: Record = { + apikey: SUPABASE_SERVICE_ROLE_KEY_VALUE, + Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY_VALUE}`, + }; + + const response = await fetch(`${SUPABASE_URL_VALUE}/rest/v1/`, { + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to read Supabase OpenAPI schema: ${response.status} ${response.statusText}`, + ); + } + + const spec = (await response.json()) as OpenApiSpec; + return new Set(Object.keys(spec.paths ?? {})); +} + +async function verifyTargetTables(rounds: number[]): Promise { + const restPaths = await fetchExposedRestPaths(); + const missingTables = rounds + .map((round) => ROUND_TABLES[round]) + .filter((table) => !restPaths.has(`/${table}`)); + + if (missingTables.length > 0) { + throw new Error( + [ + `Target tables are not exposed by the Supabase Data API: ${missingTables.join(", ")}`, + "The service-role key can write existing tables, but it cannot create missing tables.", + "Apply supabase/migrations/20260423_mht_cet_2025_rounds_2_3_4.sql first, or use a DATABASE_URL / Management API access token for DDL.", + ].join(" "), + ); + } +} + +function verifyExpectedRowCount(round: number, actualRows: number): void { + const expectedRows = EXPECTED_ROW_COUNTS[round]; + if (expectedRows !== actualRows) { + throw new Error( + `Unexpected row count for round ${round}: expected ${expectedRows}, got ${actualRows}`, + ); + } +} + +async function readCsv(filePath: string): Promise { + return new Promise((resolve, reject) => { + const rows: InsertRow[] = []; + createReadStream(filePath) + .pipe(parse({ columns: true, skip_empty_lines: true, trim: false })) + .on("data", (row: CsvRow) => rows.push(mapRow(row))) + .on("end", () => resolve(rows)) + .on("error", reject); + }); +} + +async function clearTable(table: string): Promise { + // Delete all rows. Filter `id is not null` matches every row but satisfies the + // safety requirement that delete must include a where clause. + const { error } = await supabase.from(table).delete().not("id", "is", null); + if (error) { + throw new Error(`Failed to clear ${table}: ${error.message}`); + } +} + +async function getCount(table: string): Promise { + const { count, error } = await supabase + .from(table) + .select("*", { count: "exact", head: true }); + if (error) throw new Error(`Failed to count ${table}: ${error.message}`); + return count ?? 0; +} + +async function insertChunk( + table: string, + chunk: InsertRow[], + attempt = 1, +): Promise { + const { error } = await supabase.from(table).insert(chunk); + if (!error) return; + if (attempt >= 4) { + throw new Error( + `Insert failed for ${table} after ${attempt} attempts: ${error.message}`, + ); + } + const backoffMs = 500 * 2 ** (attempt - 1); + console.warn( + ` retry ${attempt} for ${table} (${chunk.length} rows): ${error.message}`, + ); + await new Promise((r) => setTimeout(r, backoffMs)); + return insertChunk(table, chunk, attempt + 1); +} + +async function uploadRound(round: number, dryRun: boolean): Promise { + const table = ROUND_TABLES[round]; + const csvPath = path.join( + HANDOFF_ROOT, + `round-${round}`, + "combined_cutoffs_with_status.csv", + ); + + console.log(`\n=== Round ${round} -> ${table} ===`); + console.log(`Reading ${csvPath}`); + const rows = await readCsv(csvPath); + console.log(`Parsed ${rows.length} rows`); + verifyExpectedRowCount(round, rows.length); + + // Sanity: schema parity check + const sectionSet = new Set(rows.map((r) => r.seat_allocation_section)); + const categorySet = new Set(rows.map((r) => r.category)); + console.log( + `Distinct sections: ${sectionSet.size} | distinct categories: ${categorySet.size}`, + ); + + if (dryRun) { + console.log("Dry-run: skipping clear + insert"); + return; + } + + const before = await getCount(table); + console.log(`Existing rows in ${table}: ${before}`); + if (before > 0) { + console.log("Clearing existing rows..."); + await clearTable(table); + const after = await getCount(table); + if (after !== 0) { + throw new Error(`Clear failed; ${after} rows remain in ${table}`); + } + } + + // Chunk the work + const chunks: InsertRow[][] = []; + for (let i = 0; i < rows.length; i += CHUNK_SIZE) { + chunks.push(rows.slice(i, i + CHUNK_SIZE)); + } + console.log( + `Inserting ${rows.length} rows in ${chunks.length} chunks of ${CHUNK_SIZE} (concurrency=${CONCURRENCY})`, + ); + + let completed = 0; + const queue = chunks.slice(); + async function worker() { + while (queue.length) { + const chunk = queue.shift()!; + await insertChunk(table, chunk); + completed += 1; + if (completed % 10 === 0 || completed === chunks.length) { + console.log(` ${completed}/${chunks.length} chunks done`); + } + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); + + const final = await getCount(table); + console.log(`Final row count in ${table}: ${final}`); + if (final !== rows.length) { + throw new Error( + `Row count mismatch for ${table}: inserted ${rows.length} but table has ${final}`, + ); + } +} + +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const roundsArg = args.find((a) => a.startsWith("--rounds=")); + const positionalRounds = args + .filter((arg) => !arg.startsWith("--")) + .map((value) => parseInt(value.trim(), 10)) + .filter((value) => Number.isInteger(value)); + const roundsFromFlag = roundsArg + ? roundsArg + .replace("--rounds=", "") + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isInteger(n)) + : []; + + const rounds = Array.from( + new Set( + (roundsFromFlag.length > 0 || positionalRounds.length > 0 + ? [...roundsFromFlag, ...positionalRounds] + : [2, 3, 4] + ).filter((value) => Number.isInteger(value)), + ), + ); + + for (const r of rounds) { + if (!ROUND_TABLES[r]) { + throw new Error(`Unsupported round: ${r}`); + } + } + + console.log( + `Uploading rounds: ${rounds.join(", ")}${dryRun ? " (dry-run)" : ""}`, + ); + if (!dryRun) { + console.log("Verifying target tables are available via the Data API..."); + await verifyTargetTables(rounds); + } + for (const round of rounds) { + await uploadRound(round, dryRun); + } + console.log("\nAll rounds processed."); +} + +main().catch((err) => { + console.error("\nUpload failed:", err); + process.exit(1); +}); diff --git a/scripts/validate-mht-cet-question-bank.ts b/scripts/validate-mht-cet-question-bank.ts new file mode 100644 index 0000000..c41ca65 --- /dev/null +++ b/scripts/validate-mht-cet-question-bank.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; + +import { + validateQuestionImportRows, + type QuestionImportValidationMode, +} from "../lib/mht-cet/questions/validate-question-import"; + +function loadRows(filePath: string) { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + + if (Array.isArray(parsed)) { + return parsed; + } + + if ( + parsed && + typeof parsed === "object" && + Array.isArray((parsed as { questions?: unknown }).questions) + ) { + return (parsed as { questions: unknown[] }).questions; + } + + throw new Error( + "Question bank JSON must be an array or an object with a questions array.", + ); +} + +const filePath = process.argv[2]; +const mode: QuestionImportValidationMode = process.argv.includes("--production") + ? "production" + : "development"; + +if (!filePath) { + console.error( + "Usage: npx tsx scripts/validate-mht-cet-question-bank.ts [--production]", + ); + process.exit(1); +} + +const rows = loadRows(filePath); +const result = validateQuestionImportRows(rows, { mode }); +const errorCount = result.errors.filter( + (error) => error.severity === "error", +).length; +const warningCount = result.errors.filter( + (error) => error.severity === "warning", +).length; + +console.log(`Validated ${rows.length} rows in ${mode} mode.`); +console.log(`Accepted rows: ${result.validRows.length}`); +console.log(`Errors: ${errorCount}`); +console.log(`Warnings: ${warningCount}`); + +for (const issue of result.errors) { + console.log( + `${issue.severity.toUpperCase()} row ${issue.rowNumber} ${issue.fieldName}: ${issue.message}`, + ); +} + +if (errorCount > 0) { + process.exit(1); +} diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..a3ad880 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,4 @@ +# Supabase +.branches +.temp +.env diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..128de16 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,275 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "deetnuts" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` is always included. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. `public` is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory. For example: +# sql_paths = ['./seeds/*.sql', '../project-src/seeds/*-load-testing.sql'] +sql_paths = ['./seed.sql'] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[storage.image_transformation] +enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control use of MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = true +verify_enabled = true + +# Configure Multi-factor-authentication via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure Multi-factor-authentication via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 + +# Use these configurations to customize your Edge Function. +# [functions.MY_FUNCTION_NAME] +# enabled = true +# verify_jwt = true +# import_map = "./functions/MY_FUNCTION_NAME/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20250613104344_remote_baseline.sql b/supabase/migrations/20250613104344_remote_baseline.sql new file mode 100644 index 0000000..481516d --- /dev/null +++ b/supabase/migrations/20250613104344_remote_baseline.sql @@ -0,0 +1,9 @@ +-- Remote baseline placeholder. +-- +-- The hosted Supabase project already has migration version 20250613104344 in +-- supabase_migrations.schema_migrations, but this repository did not contain +-- the corresponding local migration file when local Supabase config was added. +-- Keep this file intentionally empty so Supabase CLI history checks can compare +-- local and hosted migration versions without changing database schema. + +select 1; \ No newline at end of file diff --git a/supabase/migrations/20260423_mht_cet_2025_rounds_2_3_4.sql b/supabase/migrations/20260423_mht_cet_2025_rounds_2_3_4.sql new file mode 100644 index 0000000..3ee681f --- /dev/null +++ b/supabase/migrations/20260423_mht_cet_2025_rounds_2_3_4.sql @@ -0,0 +1,100 @@ +-- 2025 MHT-CET CAP Rounds 2, 3, 4 cutoff tables. +-- Explicit column DDL (no LIKE template dependency). +-- Schema matches public."2025_mht_cet_round_one_cutoffs". +-- +-- Apply via Supabase SQL editor. Idempotent. + +create table if not exists public."2025_mht_cet_round_two_cutoffs" ( + id text primary key, + created timestamptz default now(), + updated timestamptz default now(), + college_code text, + college_name text, + course_code text, + course_name text, + category text, + seat_allocation_section text, + cutoff_score numeric, + last_rank bigint, + total_admitted int, + status text, + home_university text +); + +create table if not exists public."2025_mht_cet_round_three_cutoffs" ( + id text primary key, + created timestamptz default now(), + updated timestamptz default now(), + college_code text, + college_name text, + course_code text, + course_name text, + category text, + seat_allocation_section text, + cutoff_score numeric, + last_rank bigint, + total_admitted int, + status text, + home_university text +); + +create table if not exists public."2025_mht_cet_round_four_cutoffs" ( + id text primary key, + created timestamptz default now(), + updated timestamptz default now(), + college_code text, + college_name text, + course_code text, + course_name text, + category text, + seat_allocation_section text, + cutoff_score numeric, + last_rank bigint, + total_admitted int, + status text, + home_university text +); + +-- pg_trgm is required for the gin trgm indexes (no-op if already installed). +create extension if not exists pg_trgm; + +-- Round 2 indexes +create index if not exists idx_mht_2025_round2_college_code + on public."2025_mht_cet_round_two_cutoffs" (college_code); +create index if not exists idx_mht_2025_round2_cutoff_score + on public."2025_mht_cet_round_two_cutoffs" (cutoff_score); +create index if not exists idx_mht_2025_round2_last_rank + on public."2025_mht_cet_round_two_cutoffs" (last_rank); +create index if not exists idx_mht_2025_round2_college_course_trgm + on public."2025_mht_cet_round_two_cutoffs" + using gin (college_name gin_trgm_ops, course_name gin_trgm_ops); + +-- Round 3 indexes +create index if not exists idx_mht_2025_round3_college_code + on public."2025_mht_cet_round_three_cutoffs" (college_code); +create index if not exists idx_mht_2025_round3_cutoff_score + on public."2025_mht_cet_round_three_cutoffs" (cutoff_score); +create index if not exists idx_mht_2025_round3_last_rank + on public."2025_mht_cet_round_three_cutoffs" (last_rank); +create index if not exists idx_mht_2025_round3_college_course_trgm + on public."2025_mht_cet_round_three_cutoffs" + using gin (college_name gin_trgm_ops, course_name gin_trgm_ops); + +-- Round 4 indexes +create index if not exists idx_mht_2025_round4_college_code + on public."2025_mht_cet_round_four_cutoffs" (college_code); +create index if not exists idx_mht_2025_round4_cutoff_score + on public."2025_mht_cet_round_four_cutoffs" (cutoff_score); +create index if not exists idx_mht_2025_round4_last_rank + on public."2025_mht_cet_round_four_cutoffs" (last_rank); +create index if not exists idx_mht_2025_round4_college_course_trgm + on public."2025_mht_cet_round_four_cutoffs" + using gin (college_name gin_trgm_ops, course_name gin_trgm_ops); + +-- RLS (matches existing MHT-CET tables). +alter table public."2025_mht_cet_round_two_cutoffs" enable row level security; +alter table public."2025_mht_cet_round_three_cutoffs" enable row level security; +alter table public."2025_mht_cet_round_four_cutoffs" enable row level security; + +-- Force PostgREST schema cache reload so the new tables become queryable. +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/20260425_mht_cet_testing_platform.sql b/supabase/migrations/20260425_mht_cet_testing_platform.sql new file mode 100644 index 0000000..85d81ab --- /dev/null +++ b/supabase/migrations/20260425_mht_cet_testing_platform.sql @@ -0,0 +1,269 @@ +create extension if not exists pgcrypto; +create extension if not exists pg_trgm; + +create table if not exists public.mht_cet_question_sources ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + source_type text not null check (source_type in ('official_notice', 'official_mock', 'candidate_export', 'licensed_provider', 'manual_entry', 'test_fixture')), + title text not null, + year int check (year between 2000 and 2100), + exam_group text not null default 'pcm' check (exam_group in ('pcm', 'pcb')), + source_url text, + file_name text, + file_sha256 text, + license_note text not null, + verification_status text not null default 'draft' check (verification_status in ('draft', 'validated', 'approved', 'rejected', 'archived')), + reviewed_by uuid references auth.users(id), + reviewed_at timestamptz, + unique (file_sha256) +); + +create table if not exists public.mht_cet_chapters ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + subject text not null check (subject in ('mathematics', 'physics', 'chemistry')), + standard int not null check (standard in (11, 12)), + slug text not null, + name text not null, + official boolean not null default false, + active boolean not null default true, + sort_order int not null default 0, + unique (subject, standard, slug) +); + +insert into public.mht_cet_chapters (subject, standard, slug, name, official, active, sort_order) +values + ('physics', 11, 'motion-in-a-plane', 'Motion in a plane', true, true, 1), + ('physics', 11, 'laws-of-motion', 'Laws of Motion', true, true, 2), + ('physics', 11, 'gravitation', 'Gravitation', true, true, 3), + ('physics', 11, 'thermal-properties-of-matter', 'Thermal properties of matter', true, true, 4), + ('physics', 11, 'sound', 'Sound', true, true, 5), + ('physics', 11, 'optics', 'Optics', true, true, 6), + ('physics', 11, 'electrostatics', 'Electrostatics', true, true, 7), + ('physics', 11, 'semiconductors', 'Semiconductors', true, true, 8), + ('chemistry', 11, 'some-basic-concepts-of-chemistry', 'Some Basic concepts of chemistry', true, true, 1), + ('chemistry', 11, 'structure-of-atom', 'Structure of atom', true, true, 2), + ('chemistry', 11, 'chemical-bonding', 'Chemical Bonding', true, true, 3), + ('chemistry', 11, 'redox-reactions', 'Redox reactions', true, true, 4), + ('chemistry', 11, 'elements-of-group-1-and-2', 'Elements of group 1 and 2', true, true, 5), + ('chemistry', 11, 'states-of-matter', 'States of Matter (Gaseous and Liquids)', true, true, 6), + ('chemistry', 11, 'adsorption-and-colloids', 'Adsorption and colloids (Surface Chemistry)', true, true, 7), + ('chemistry', 11, 'hydrocarbons', 'Hydrocarbons', true, true, 8), + ('chemistry', 11, 'basic-principles-of-organic-chemistry', 'Basic principles of organic chemistry', true, true, 9), + ('mathematics', 11, 'trigonometry-ii', 'Trigonometry II', true, true, 1), + ('mathematics', 11, 'straight-line', 'Straight Line', true, true, 2), + ('mathematics', 11, 'circle', 'Circle', true, true, 3), + ('mathematics', 11, 'measures-of-dispersion', 'Measures of Dispersion', true, true, 4), + ('mathematics', 11, 'probability', 'Probability', true, true, 5), + ('mathematics', 11, 'complex-numbers', 'Complex Numbers', true, true, 6), + ('mathematics', 11, 'permutations-and-combinations', 'Permutations and Combinations', true, true, 7), + ('mathematics', 11, 'functions', 'Functions', true, true, 8), + ('mathematics', 11, 'limits', 'Limits', true, true, 9), + ('mathematics', 11, 'continuity', 'Continuity', true, true, 10) +on conflict (subject, standard, slug) do update set + name = excluded.name, + official = excluded.official, + active = excluded.active, + sort_order = excluded.sort_order; + +create table if not exists public.mht_cet_questions ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + source_id uuid not null references public.mht_cet_question_sources(id) on delete restrict, + chapter_id uuid references public.mht_cet_chapters(id), + year int check (year between 2000 and 2100), + exam_group text not null default 'pcm' check (exam_group in ('pcm', 'pcb')), + subject text not null check (subject in ('mathematics', 'physics', 'chemistry')), + difficulty text not null default 'unknown' check (difficulty in ('unknown', 'easy', 'medium', 'hard')), + question_type text not null default 'single_correct' check (question_type in ('single_correct')), + marks numeric not null check (marks > 0), + negative_marks numeric not null default 0 check (negative_marks >= 0), + body jsonb not null, + body_text text not null, + body_sha256 text not null, + verification_status text not null default 'draft' check (verification_status in ('draft', 'validated', 'approved', 'rejected', 'archived')), + quality_flags text[] not null default '{}', + unique (body_sha256, source_id) +); + +create table if not exists public.mht_cet_question_options ( + id uuid primary key default gen_random_uuid(), + question_id uuid not null references public.mht_cet_questions(id) on delete cascade, + option_order int not null check (option_order between 1 and 8), + body jsonb not null, + body_text text not null, + unique (question_id, option_order) +); + +create table if not exists public.mht_cet_question_answers ( + question_id uuid primary key references public.mht_cet_questions(id) on delete cascade, + correct_option_ids uuid[] not null, + explanation jsonb, + explanation_text text, + updated_at timestamptz not null default now() +); + +create table if not exists public.mht_cet_question_import_batches ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + source_id uuid not null references public.mht_cet_question_sources(id) on delete restrict, + imported_by uuid references auth.users(id), + file_name text not null, + file_sha256 text not null, + total_rows int not null default 0, + accepted_rows int not null default 0, + rejected_rows int not null default 0, + status text not null default 'validated' check (status in ('validated', 'imported', 'failed')) +); + +create table if not exists public.mht_cet_question_import_errors ( + id uuid primary key default gen_random_uuid(), + batch_id uuid not null references public.mht_cet_question_import_batches(id) on delete cascade, + row_number int not null, + field_name text not null, + severity text not null check (severity in ('warning', 'error')), + message text not null +); + +create table if not exists public.mht_cet_mock_attempts ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + user_id uuid not null references auth.users(id) on delete cascade, + status text not null default 'in_progress' check (status in ('in_progress', 'submitted', 'expired', 'abandoned')), + exam_group text not null default 'pcm' check (exam_group in ('pcm', 'pcb')), + duration_seconds int not null check (duration_seconds between 300 and 21600), + seed text not null, + started_at timestamptz not null default now(), + ends_at timestamptz not null, + submitted_at timestamptz, + question_count int not null default 0, + score_raw numeric not null default 0, + max_score numeric not null default 0, + correct_count int not null default 0, + wrong_count int not null default 0, + unanswered_count int not null default 0, + time_spent_seconds int not null default 0, + config jsonb not null default '{}' +); + +create table if not exists public.mht_cet_mock_attempt_questions ( + attempt_id uuid not null references public.mht_cet_mock_attempts(id) on delete cascade, + question_id uuid not null references public.mht_cet_questions(id) on delete restrict, + position int not null, + subject text not null check (subject in ('mathematics', 'physics', 'chemistry')), + chapter_id uuid references public.mht_cet_chapters(id), + marks numeric not null, + primary key (attempt_id, question_id), + unique (attempt_id, position) +); + +create table if not exists public.mht_cet_mock_responses ( + attempt_id uuid not null, + question_id uuid not null, + selected_option_ids uuid[] not null default '{}', + visited boolean not null default false, + marked_for_review boolean not null default false, + time_spent_seconds int not null default 0 check (time_spent_seconds >= 0), + updated_at timestamptz not null default now(), + primary key (attempt_id, question_id), + foreign key (attempt_id, question_id) + references public.mht_cet_mock_attempt_questions(attempt_id, question_id) + on delete cascade +); + +create table if not exists public.mht_cet_mock_attempt_events ( + id uuid primary key default gen_random_uuid(), + attempt_id uuid not null references public.mht_cet_mock_attempts(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + event_type text not null check (event_type in ('created', 'response_saved', 'submitted', 'expired')), + metadata jsonb not null default '{}', + created_at timestamptz not null default now() +); + +create index if not exists idx_mht_cet_questions_approved_lookup + on public.mht_cet_questions (exam_group, subject, year, verification_status); +create index if not exists idx_mht_cet_questions_chapter + on public.mht_cet_questions (chapter_id); +create index if not exists idx_mht_cet_questions_body_trgm + on public.mht_cet_questions using gin (body_text gin_trgm_ops); +create index if not exists idx_mht_cet_attempts_user + on public.mht_cet_mock_attempts (user_id, created_at desc); +create index if not exists idx_mht_cet_attempt_events_attempt + on public.mht_cet_mock_attempt_events (attempt_id, created_at desc); + +alter table public.mht_cet_question_sources enable row level security; +alter table public.mht_cet_chapters enable row level security; +alter table public.mht_cet_questions enable row level security; +alter table public.mht_cet_question_options enable row level security; +alter table public.mht_cet_question_answers enable row level security; +alter table public.mht_cet_question_import_batches enable row level security; +alter table public.mht_cet_question_import_errors enable row level security; +alter table public.mht_cet_mock_attempts enable row level security; +alter table public.mht_cet_mock_attempt_questions enable row level security; +alter table public.mht_cet_mock_responses enable row level security; +alter table public.mht_cet_mock_attempt_events enable row level security; + +create policy "public read active chapters" on public.mht_cet_chapters + for select using (active = true); +create policy "public read approved questions" on public.mht_cet_questions + for select using ( + verification_status = 'approved' + and exists ( + select 1 from public.mht_cet_question_sources s + where s.id = source_id and s.verification_status = 'approved' + ) + ); +create policy "public read approved options" on public.mht_cet_question_options + for select using ( + exists ( + select 1 + from public.mht_cet_questions q + join public.mht_cet_question_sources s on s.id = q.source_id + where q.id = question_id + and q.verification_status = 'approved' + and s.verification_status = 'approved' + ) + ); +create policy "users read own attempts" on public.mht_cet_mock_attempts + for select using ((select auth.uid()) = user_id); +create policy "users insert own attempts" on public.mht_cet_mock_attempts + for insert with check ((select auth.uid()) = user_id); +create policy "users update own attempts" on public.mht_cet_mock_attempts + for update using ((select auth.uid()) = user_id); +create policy "users read own attempt questions" on public.mht_cet_mock_attempt_questions + for select using ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id and a.user_id = (select auth.uid()) + ) + ); +create policy "users read own responses" on public.mht_cet_mock_responses + for select using ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id and a.user_id = (select auth.uid()) + ) + ); +create policy "users read own attempt events" on public.mht_cet_mock_attempt_events + for select using (user_id = (select auth.uid())); +create policy "users upsert own responses" on public.mht_cet_mock_responses + for all using ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id and a.user_id = (select auth.uid()) + ) + ) + with check ( + exists ( + select 1 from public.mht_cet_mock_attempts a + where a.id = attempt_id + and a.user_id = (select auth.uid()) + and a.status = 'in_progress' + and a.ends_at > now() + ) + ); + +notify pgrst, 'reload schema'; \ No newline at end of file diff --git a/types/svg.d.ts b/types/svg.d.ts new file mode 100644 index 0000000..be09785 --- /dev/null +++ b/types/svg.d.ts @@ -0,0 +1,6 @@ +declare module "*.svg" { + import type { StaticImageData } from "next/image"; + + const content: StaticImageData; + export default content; +}