From cbd397dae9c145a7664c2d4432f5721cc2409eb1 Mon Sep 17 00:00:00 2001 From: Saad Khan Date: Tue, 21 Jul 2026 14:04:49 +0500 Subject: [PATCH 1/2] created ID auth flow and quizBank --- README-quiz-bank.md | 45 ++++ package-lock.json | 45 +--- src/lib/auth/profile.functions.ts | 6 +- src/lib/proctoring/proctoring.functions.ts | 11 +- src/lib/quizzes/quiz-bank.functions.ts | 191 ++++++++++++++++ src/routeTree.gen.ts | 31 +++ src/routes/_authenticated/dashboard.tsx | 8 +- src/routes/_authenticated/profile.tsx | 23 +- src/routes/_authenticated/quiz-bank.tsx | 214 ++++++++++++++++++ .../quizzes.$quizId.report.$attemptId.tsx | 1 + .../quizzes.$quizId.report.index.tsx | 11 +- src/routes/_authenticated/quizzes.index.tsx | 19 +- src/routes/login.tsx | 42 +++- src/routes/signup.tsx | 35 ++- ...260721000000_student_id_signup_profile.sql | 30 +++ .../20260721010000_quiz_bank_management.sql | 74 ++++++ supabase/seed-quiz-bank.sql | 107 +++++++++ 17 files changed, 822 insertions(+), 71 deletions(-) create mode 100644 README-quiz-bank.md create mode 100644 src/lib/quizzes/quiz-bank.functions.ts create mode 100644 src/routes/_authenticated/quiz-bank.tsx create mode 100644 supabase/migrations/20260721000000_student_id_signup_profile.sql create mode 100644 supabase/migrations/20260721010000_quiz_bank_management.sql create mode 100644 supabase/seed-quiz-bank.sql diff --git a/README-quiz-bank.md b/README-quiz-bank.md new file mode 100644 index 0000000..6ed2214 --- /dev/null +++ b/README-quiz-bank.md @@ -0,0 +1,45 @@ +# Quiz Bank Management System + +## Architecture overview + +The quiz bank feature is built as a teacher-first extension of the existing quiz flow: + +- Subjects and reusable bank questions are stored separately from individual quizzes. +- Teachers browse the bank, select multiple questions, and create a quiz from that selection. +- Each quiz created from the bank can generate shuffled versions A/B/C automatically. +- The existing quiz and auth layers remain in place, so student participation continues to work without disruption. + +## Core entities + +- Subject: top-level category for bank questions. +- QuizBankQuestion: reusable question item with subject, options, answer, difficulty, marks, and status. +- Quiz: teacher-owned quiz wrapper with duration, marks, publish state, and availability window. +- QuizQuestion: mapping between a quiz and selected bank questions. +- QuizVersion: generated randomized version metadata for A/B/C. + +## Business rules + +- Only authenticated teachers can manage the bank and create quizzes. +- A quiz can contain questions from a single subject. +- Question edits in the bank do not mutate previously published quiz snapshots; the system uses a reusable bank reference model and version metadata. +- Published quizzes remain read-only in this initial implementation unless duplicated later. + +## API surface + +Teacher-facing server functions: + +- listQuizBankSubjects +- listQuizBankQuestions +- createQuizFromBank +- generateQuizVersions +- listQuizVersions + +## Seed data + +The SQL seed file populates bank questions for the required subjects and includes a mix of Easy, Medium, and Hard items. + +## Deployment notes + +1. Apply the new Supabase migration. +2. Run the seed SQL to load dummy questions. +3. Open the teacher dashboard and visit Quiz Bank. diff --git a/package-lock.json b/package-lock.json index 48ca9a5..361b921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,7 +124,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -384,7 +383,6 @@ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -396,7 +394,6 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -3605,7 +3602,6 @@ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", "license": "MIT", - "peer": true, "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", @@ -3682,7 +3678,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-start/-/react-start-1.168.26.tgz", "integrity": "sha512-ZzNecqKWC0p2643/kIcFUdsMrXZ8A4dXm4Yfe9zV/Y0u14MtSkh/Sk76RQYIU5S84VyDyKhHo0Ffh8HQbLdvFw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/react-router": "1.170.16", "@tanstack/react-start-client": "1.168.14", @@ -4254,7 +4249,6 @@ "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4265,7 +4259,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4276,7 +4269,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4326,7 +4318,6 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -4583,7 +4574,6 @@ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", @@ -4605,7 +4595,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4811,7 +4800,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5015,7 +5003,6 @@ "integrity": "sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "srvx": ">=0.11.5" }, @@ -5187,7 +5174,6 @@ "integrity": "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", @@ -5305,8 +5291,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -5459,7 +5444,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5520,7 +5504,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6846,7 +6829,6 @@ "integrity": "sha512-ffaSHK00a7YDlDizoEHwcxPwpQpdBRRA8k42ymTsRnfl3ipGeKgv4gnPr6DgmCNTo4tYVPK3bHBEv1gNhWpo/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", @@ -6906,18 +6888,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/unstorage": { "version": "2.0.0-alpha.7", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz", @@ -7074,8 +7044,7 @@ "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-2.0.0-alpha.3.tgz", "integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ohash": { "version": "2.0.11", @@ -7237,7 +7206,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -7414,7 +7382,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7500,7 +7467,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7532,7 +7498,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7545,7 +7510,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -7828,7 +7792,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -7918,7 +7881,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -8238,7 +8200,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8447,7 +8408,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8522,7 +8482,6 @@ "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", diff --git a/src/lib/auth/profile.functions.ts b/src/lib/auth/profile.functions.ts index 6f9ddbc..9190be7 100644 --- a/src/lib/auth/profile.functions.ts +++ b/src/lib/auth/profile.functions.ts @@ -38,9 +38,13 @@ export const updateMyProfile = createServerFn({ method: "POST" }) .inputValidator((input: unknown) => updateSchema.parse(input)) .handler(async ({ data, context }) => { const { supabase, userId } = context; + const payload = { + ...data, + student_id: data.student_id ? data.student_id.toUpperCase() : data.student_id, + }; const { data: updated, error } = await supabase .from("profiles") - .update(data) + .update(payload) .eq("id", userId) .select() .maybeSingle(); diff --git a/src/lib/proctoring/proctoring.functions.ts b/src/lib/proctoring/proctoring.functions.ts index de4f5c7..e142da7 100644 --- a/src/lib/proctoring/proctoring.functions.ts +++ b/src/lib/proctoring/proctoring.functions.ts @@ -164,11 +164,11 @@ export const listQuizAttemptsWithRisk = createServerFn({ method: "POST" }) if (error) throw new Error(error.message); const studentIds = [...new Set((attempts ?? []).map((a) => a.student_id))]; - const profMap = new Map(); + const profMap = new Map(); if (studentIds.length > 0) { const { data: profs } = await supabase - .from("profiles").select("id, full_name, email").in("id", studentIds); - (profs ?? []).forEach((p) => profMap.set(p.id, { full_name: p.full_name, email: p.email })); + .from("profiles").select("id, full_name, email, student_id").in("id", studentIds); + (profs ?? []).forEach((p) => profMap.set(p.id, { full_name: p.full_name, email: p.email, student_id: p.student_id })); } const rows = await Promise.all( @@ -180,12 +180,13 @@ export const listQuizAttemptsWithRisk = createServerFn({ method: "POST" }) .select("*", { count: "exact", head: true }) .eq("attempt_id", a.id) .eq("severity", "critical"); - const prof = profMap.get(a.student_id) ?? { full_name: null, email: null }; + const prof = profMap.get(a.student_id) ?? { full_name: null, email: null, student_id: null }; return { attempt_id: a.id, student_id: a.student_id, full_name: prof.full_name, email: prof.email, + student_id_value: prof.student_id, status: a.status, score: a.score, max_score: a.max_score, @@ -221,7 +222,7 @@ export const getAttemptProctoring = createServerFn({ method: "POST" }) if (!isTeacher && !isStudent) throw new Error("Not authorized"); const { data: prof } = await supabase - .from("profiles").select("id, full_name, email").eq("id", a.student_id).maybeSingle(); + .from("profiles").select("id, full_name, email, student_id").eq("id", a.student_id).maybeSingle(); const { data: snaps } = await supabase .from("proctoring_snapshots") diff --git a/src/lib/quizzes/quiz-bank.functions.ts b/src/lib/quizzes/quiz-bank.functions.ts new file mode 100644 index 0000000..3a34d22 --- /dev/null +++ b/src/lib/quizzes/quiz-bank.functions.ts @@ -0,0 +1,191 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; + +const teacherOnlySchema = z.object({ + subjectId: z.string().uuid().optional().nullable(), + search: z.string().trim().max(120).optional().nullable(), + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(100).default(20), + difficulty: z.enum(["all", "Easy", "Medium", "Hard"]).default("all"), + status: z.enum(["all", "Draft", "Published"]).default("all"), +}); + +const createQuizFromBankSchema = z.object({ + title: z.string().trim().min(1).max(200), + subjectId: z.string().uuid(), + durationMinutes: z.number().int().min(1).max(600), + totalMarks: z.number().int().min(1).max(1000), + passingMarks: z.number().int().min(0).max(1000), + availableFrom: z.string().datetime().optional().nullable(), + availableUntil: z.string().datetime().optional().nullable(), + questionIds: z.array(z.string().uuid()).min(1), + publishNow: z.boolean().default(false), +}); + +const createVersionsSchema = z.object({ quizId: z.string().uuid() }); + +function shuffle(items: T[]) { + const copy = [...items]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + const temp = copy[index]; + copy[index] = copy[swapIndex]; + copy[swapIndex] = temp; + } + return copy; +} + +export const listQuizBankSubjects = createServerFn({ method: "GET" }) + .middleware([requireSupabaseAuth]) + .handler(async ({ context }) => { + const { supabase } = context; + const { data, error } = await (supabase as any) + .from("subjects") + .select("id, name") + .order("name", { ascending: true }); + if (error) throw new Error(error.message); + return { subjects: data ?? [] }; + }); + +export const listQuizBankQuestions = createServerFn({ method: "POST" }) + .middleware([requireSupabaseAuth]) + .inputValidator((i: unknown) => teacherOnlySchema.parse(i)) + .handler(async ({ data, context }) => { + const { supabase } = context; + const { search, subjectId, page, pageSize, difficulty, status } = data; + let query = (supabase as any) + .from("quiz_bank_questions") + .select("id, question_text, difficulty, marks, status, subject_id, explanation, option_a, option_b, option_c, option_d, correct_option, subjects(name)", { count: "exact" }) + .order("created_at", { ascending: false }); + + if (subjectId) { + query = query.eq("subject_id", subjectId); + } + + if (search) { + query = query.or(`question_text.ilike.%${search}%,option_a.ilike.%${search}%,option_b.ilike.%${search}%`); + } + + if (difficulty !== "all") { + query = query.eq("difficulty", difficulty); + } + + if (status !== "all") { + query = query.eq("status", status.toLowerCase()); + } + + const start = (page - 1) * pageSize; + query = query.range(start, start + pageSize - 1); + + const { data: rows, error, count } = await query; + if (error) throw new Error(error.message); + return { rows: rows ?? [], total: count ?? 0 }; + }); + +export const createQuizFromBank = createServerFn({ method: "POST" }) + .middleware([requireSupabaseAuth]) + .inputValidator((i: unknown) => createQuizFromBankSchema.parse(i)) + .handler(async ({ data, context }) => { + const { supabase, userId } = context; + + const { data: subject, error: subjectErr } = await (supabase as any) + .from("subjects") + .select("id") + .eq("id", data.subjectId) + .maybeSingle(); + if (subjectErr) throw new Error(subjectErr.message); + if (!subject) throw new Error("Subject not found."); + + const { data: quiz, error: quizErr } = await (supabase as any) + .from("quizzes") + .insert({ + teacher_id: userId, + title: data.title, + subject_id: data.subjectId, + description: `Generated from quiz bank (${data.questionIds.length} questions)`, + time_limit_minutes: data.durationMinutes, + duration_minutes: data.durationMinutes, + total_marks: data.totalMarks, + passing_score: data.passingMarks, + passing_marks: data.passingMarks, + available_from: data.availableFrom || null, + available_until: data.availableUntil || null, + status: data.publishNow ? "published" : "draft", + }) + .select() + .single(); + if (quizErr) throw new Error(quizErr.message); + + const questionRows = data.questionIds.map((questionId, index) => ({ + quiz_id: quiz.id, + question_id: questionId, + display_order: index + 1, + })); + + const { error: linkErr } = await (supabase as any) + .from("quiz_questions") + .insert(questionRows); + if (linkErr) throw new Error(linkErr.message); + + return { quiz }; + }); + +export const generateQuizVersions = createServerFn({ method: "POST" }) + .middleware([requireSupabaseAuth]) + .inputValidator((i: unknown) => createVersionsSchema.parse(i)) + .handler(async ({ data, context }) => { + const { supabase } = context; + const { data: existingVersions, error: versionErr } = await (supabase as any) + .from("quiz_versions") + .select("id, version_name") + .eq("quiz_id", data.quizId); + if (versionErr) throw new Error(versionErr.message); + if ((existingVersions ?? []).length >= 3) { + return { versions: existingVersions ?? [] }; + } + + const { data: linkedQuestions, error: linkErr } = await (supabase as any) + .from("quiz_questions") + .select("question_id") + .eq("quiz_id", data.quizId) + .order("display_order", { ascending: true }); + if (linkErr) throw new Error(linkErr.message); + + const questionIds = (linkedQuestions ?? []).map((row: any) => row.question_id); + if (questionIds.length === 0) { + throw new Error("This quiz does not have any bank questions yet."); + } + + const versions = ["A", "B", "C"].map((versionName) => { + const shuffledQuestions = shuffle(questionIds); + const optionOrder = shuffledQuestions.map(() => shuffle(["option_a", "option_b", "option_c", "option_d"])); + return { + quiz_id: data.quizId, + version_name: versionName, + question_order: shuffledQuestions, + option_order: optionOrder, + }; + }); + + const { data: inserted, error: insertErr } = await (supabase as any) + .from("quiz_versions") + .insert(versions) + .select(); + if (insertErr) throw new Error(insertErr.message); + return { versions: inserted ?? [] }; + }); + +export const listQuizVersions = createServerFn({ method: "POST" }) + .middleware([requireSupabaseAuth]) + .inputValidator((i: unknown) => createVersionsSchema.parse(i)) + .handler(async ({ data, context }) => { + const { supabase } = context; + const { data: versions, error } = await (supabase as any) + .from("quiz_versions") + .select("id, version_name, question_order, option_order") + .eq("quiz_id", data.quizId) + .order("version_name", { ascending: true }); + if (error) throw new Error(error.message); + return { versions: versions ?? [] }; + }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 26e2e00..5004166 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -1,3 +1,13 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + import { Route as rootRouteImport } from './routes/__root' import { Route as SignupRouteImport } from './routes/signup' import { Route as ResetPasswordRouteImport } from './routes/reset-password' @@ -5,6 +15,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as ForgotPasswordRouteImport } from './routes/forgot-password' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthenticatedQuizBankRouteImport } from './routes/_authenticated/quiz-bank' import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile' import { Route as AuthenticatedJoinRouteImport } from './routes/_authenticated/join' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard' @@ -46,6 +57,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedQuizBankRoute = AuthenticatedQuizBankRouteImport.update({ + id: '/quiz-bank', + path: '/quiz-bank', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({ id: '/profile', path: '/profile', @@ -119,6 +135,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof AuthenticatedDashboardRoute '/join': typeof AuthenticatedJoinRoute '/profile': typeof AuthenticatedProfileRoute + '/quiz-bank': typeof AuthenticatedQuizBankRoute '/my-quizzes/': typeof AuthenticatedMyQuizzesIndexRoute '/quizzes/': typeof AuthenticatedQuizzesIndexRoute '/my-quizzes/$quizId/attempt': typeof AuthenticatedMyQuizzesQuizIdAttemptRoute @@ -137,6 +154,7 @@ export interface FileRoutesByTo { '/dashboard': typeof AuthenticatedDashboardRoute '/join': typeof AuthenticatedJoinRoute '/profile': typeof AuthenticatedProfileRoute + '/quiz-bank': typeof AuthenticatedQuizBankRoute '/my-quizzes': typeof AuthenticatedMyQuizzesIndexRoute '/quizzes': typeof AuthenticatedQuizzesIndexRoute '/my-quizzes/$quizId/attempt': typeof AuthenticatedMyQuizzesQuizIdAttemptRoute @@ -157,6 +175,7 @@ export interface FileRoutesById { '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute '/_authenticated/join': typeof AuthenticatedJoinRoute '/_authenticated/profile': typeof AuthenticatedProfileRoute + '/_authenticated/quiz-bank': typeof AuthenticatedQuizBankRoute '/_authenticated/my-quizzes/': typeof AuthenticatedMyQuizzesIndexRoute '/_authenticated/quizzes/': typeof AuthenticatedQuizzesIndexRoute '/_authenticated/my-quizzes/$quizId/attempt': typeof AuthenticatedMyQuizzesQuizIdAttemptRoute @@ -177,6 +196,7 @@ export interface FileRouteTypes { | '/dashboard' | '/join' | '/profile' + | '/quiz-bank' | '/my-quizzes/' | '/quizzes/' | '/my-quizzes/$quizId/attempt' @@ -195,6 +215,7 @@ export interface FileRouteTypes { | '/dashboard' | '/join' | '/profile' + | '/quiz-bank' | '/my-quizzes' | '/quizzes' | '/my-quizzes/$quizId/attempt' @@ -214,6 +235,7 @@ export interface FileRouteTypes { | '/_authenticated/dashboard' | '/_authenticated/join' | '/_authenticated/profile' + | '/_authenticated/quiz-bank' | '/_authenticated/my-quizzes/' | '/_authenticated/quizzes/' | '/_authenticated/my-quizzes/$quizId/attempt' @@ -277,6 +299,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated/quiz-bank': { + id: '/_authenticated/quiz-bank' + path: '/quiz-bank' + fullPath: '/quiz-bank' + preLoaderRoute: typeof AuthenticatedQuizBankRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/profile': { id: '/_authenticated/profile' path: '/profile' @@ -361,6 +390,7 @@ interface AuthenticatedRouteChildren { AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedJoinRoute: typeof AuthenticatedJoinRoute AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute + AuthenticatedQuizBankRoute: typeof AuthenticatedQuizBankRoute AuthenticatedMyQuizzesIndexRoute: typeof AuthenticatedMyQuizzesIndexRoute AuthenticatedQuizzesIndexRoute: typeof AuthenticatedQuizzesIndexRoute AuthenticatedMyQuizzesQuizIdAttemptRoute: typeof AuthenticatedMyQuizzesQuizIdAttemptRoute @@ -375,6 +405,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedJoinRoute: AuthenticatedJoinRoute, AuthenticatedProfileRoute: AuthenticatedProfileRoute, + AuthenticatedQuizBankRoute: AuthenticatedQuizBankRoute, AuthenticatedMyQuizzesIndexRoute: AuthenticatedMyQuizzesIndexRoute, AuthenticatedQuizzesIndexRoute: AuthenticatedQuizzesIndexRoute, AuthenticatedMyQuizzesQuizIdAttemptRoute: diff --git a/src/routes/_authenticated/dashboard.tsx b/src/routes/_authenticated/dashboard.tsx index d8c67f4..c633a3e 100644 --- a/src/routes/_authenticated/dashboard.tsx +++ b/src/routes/_authenticated/dashboard.tsx @@ -109,13 +109,19 @@ function StudentCards() { function TeacherCards() { return ( -
+
} title="Quizzes" description="Create and manage your quizzes." cta={} /> + } + title="Quiz Bank" + description="Browse bank questions, build quizzes, and generate versions." + cta={} + /> } title="Students" diff --git a/src/routes/_authenticated/profile.tsx b/src/routes/_authenticated/profile.tsx index 83f0ca2..1fcf1de 100644 --- a/src/routes/_authenticated/profile.tsx +++ b/src/routes/_authenticated/profile.tsx @@ -21,6 +21,12 @@ export const Route = createFileRoute("/_authenticated/profile")({ ), }); +const STUDENT_ID_PATTERN = /^[A-Z]{3}-\d{2}[A-Z]-\d{3}$/i; + +function normalizeStudentId(value: string) { + return value.replace(/\s+/g, "").toUpperCase(); +} + type FormState = { full_name: string; student_id: string; @@ -170,7 +176,18 @@ function ProfilePage() { className="mt-6" onSubmit={(e) => { e.preventDefault(); - mutation.mutate(form); + const normalizedStudentId = role === "student" ? normalizeStudentId(form.student_id) : form.student_id; + if (role === "student") { + if (!normalizedStudentId) { + toast.error("Student ID is required."); + return; + } + if (!STUDENT_ID_PATTERN.test(normalizedStudentId)) { + toast.error("Student ID must use the format ABC-00A-000."); + return; + } + } + mutation.mutate({ ...form, student_id: normalizedStudentId }); }} > @@ -207,9 +224,11 @@ function ProfilePage() { setForm({ ...form, student_id: e.target.value })} + onChange={(e) => setForm({ ...form, student_id: normalizeStudentId(e.target.value) })} maxLength={60} + placeholder="ABC-00A-000" /> +

Use the format ABC-00A-000.

diff --git a/src/routes/_authenticated/quiz-bank.tsx b/src/routes/_authenticated/quiz-bank.tsx new file mode 100644 index 0000000..ae5d426 --- /dev/null +++ b/src/routes/_authenticated/quiz-bank.tsx @@ -0,0 +1,214 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useServerFn } from "@tanstack/react-start"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { AppHeader } from "@/components/app-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { Loader2, PlusCircle, Sparkles } from "lucide-react"; +import { listQuizBankSubjects, listQuizBankQuestions, createQuizFromBank, generateQuizVersions } from "@/lib/quizzes/quiz-bank.functions"; +import { getMyProfile } from "@/lib/auth/profile.functions"; + +export const Route = createFileRoute("/_authenticated/quiz-bank")({ + head: () => ({ meta: [{ title: "Quiz Bank — ProctorAI" }] }), + component: QuizBankPage, +}); + +function QuizBankPage() { + const qc = useQueryClient(); + const navigate = useNavigate(); + const [subjectId, setSubjectId] = useState("all"); + const [search, setSearch] = useState(""); + const [difficulty, setDifficulty] = useState("all"); + const [status, setStatus] = useState("all"); + const [selectedIds, setSelectedIds] = useState([]); + const [title, setTitle] = useState("New quiz from bank"); + const [durationMinutes, setDurationMinutes] = useState("45"); + const [totalMarks, setTotalMarks] = useState("20"); + const [passingMarks, setPassingMarks] = useState("10"); + const [publishNow, setPublishNow] = useState(false); + + const subjectFn = useServerFn(listQuizBankSubjects); + const questionFn = useServerFn(listQuizBankQuestions); + const createQuiz = useServerFn(createQuizFromBank); + const generateVersions = useServerFn(generateQuizVersions); + const profileFn = useServerFn(getMyProfile); + + const { data: profileData } = useQuery({ queryKey: ["my-profile"], queryFn: () => profileFn() }); + const { data: subjectsData } = useQuery({ queryKey: ["quiz-bank-subjects"], queryFn: () => subjectFn() }); + const { data: bankData, isLoading } = useQuery({ + queryKey: ["quiz-bank-questions", subjectId, search, difficulty, status], + queryFn: () => questionFn({ data: { subjectId: subjectId === "all" ? null : subjectId, search, difficulty: difficulty as any, status: status as any, page: 1, pageSize: 50 } }), + enabled: profileData?.role === "teacher", + }); + + const createMutation = useMutation({ + mutationFn: () => createQuiz({ data: { title, subjectId: (subjectsData?.subjects ?? []).find((s: any) => s.id === subjectId)?.id ?? (subjectsData?.subjects?.[0]?.id ?? ""), durationMinutes: Number(durationMinutes || 45), totalMarks: Number(totalMarks || 20), passingMarks: Number(passingMarks || 10), availableFrom: null, availableUntil: null, questionIds: selectedIds, publishNow } }), + onSuccess: async ({ quiz }) => { + await generateVersions({ data: { quizId: quiz.id } }); + toast.success("Quiz created from bank questions."); + qc.invalidateQueries({ queryKey: ["quiz-bank-questions"] }); + navigate({ to: "/quizzes/$quizId/edit", params: { quizId: quiz.id } }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const rows = useMemo(() => bankData?.rows ?? [], [bankData]); + + const toggleSelected = (id: string) => { + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + }; + + if (profileData?.role !== "teacher") { + return ( +
+ +
+ + + Teachers only + This workspace is reserved for teacher quiz-bank management. + + +
+
+ ); + } + + return ( +
+ +
+
+
+

Quiz Bank

+

Browse subject questions, pick a set, and create a new quiz.

+
+ +
+ +
+ + + Bank questions + Search, filter, and select questions from the bank. + + +
+
+ + +
+
+ + +
+
+ + +
+
+ setSearch(e.target.value)} /> + {isLoading ?
Loading questions…
: null} +
+ {rows.map((question: any) => ( +
+
+ toggleSelected(question.id)} /> +
+
+

{question.question_text}

+ {question.difficulty} + {question.marks} marks + {question.status} +
+
+ A. {question.option_a} + B. {question.option_b} + C. {question.option_c} + D. {question.option_d} +
+
+
+
+ ))} +
+
+
+ + + + Create quiz + Build a new quiz from your selected questions. + + +
+ + setTitle(e.target.value)} /> +
+
+
+ + setDurationMinutes(e.target.value)} /> +
+
+ + setTotalMarks(e.target.value)} /> +
+
+
+ + setPassingMarks(e.target.value)} /> +
+
+ setPublishNow(Boolean(value))} /> + Publish immediately +
+
+
+ Selected questions: {selectedIds.length} +
+

This creates a new quiz and automatically generates versions A, B, and C.

+
+ +
+
+
+
+
+ ); +} diff --git a/src/routes/_authenticated/quizzes.$quizId.report.$attemptId.tsx b/src/routes/_authenticated/quizzes.$quizId.report.$attemptId.tsx index abb946d..209c218 100644 --- a/src/routes/_authenticated/quizzes.$quizId.report.$attemptId.tsx +++ b/src/routes/_authenticated/quizzes.$quizId.report.$attemptId.tsx @@ -71,6 +71,7 @@ function AttemptDetailPage() {

{student?.full_name || student?.email || "Student"}

+ {student?.student_id ? <>ID {student.student_id} · : null} Started {new Date(attempt.started_at).toLocaleString()} {attempt.submitted_at && <> · Submitted {new Date(attempt.submitted_at).toLocaleString()}}

diff --git a/src/routes/_authenticated/quizzes.$quizId.report.index.tsx b/src/routes/_authenticated/quizzes.$quizId.report.index.tsx index b2b577c..beab177 100644 --- a/src/routes/_authenticated/quizzes.$quizId.report.index.tsx +++ b/src/routes/_authenticated/quizzes.$quizId.report.index.tsx @@ -47,17 +47,19 @@ function ReportPage() { const matchesSearch = !q || (r.full_name ?? "").toLowerCase().includes(q) || - (r.email ?? "").toLowerCase().includes(q); + (r.email ?? "").toLowerCase().includes(q) || + (r.student_id_value ?? "").toLowerCase().includes(q); return matchesBand && matchesSearch; }); }, [data, search, band]); const exportCsv = () => { - const header = ["Student", "Email", "Status", "Score", "Risk score", "Risk band", "Critical events", "Started", "Submitted"]; + const header = ["Student", "Student ID", "Email", "Status", "Score", "Risk score", "Risk band", "Critical events", "Started", "Submitted"]; const lines = [header.join(",")]; rows.forEach((r) => { lines.push([ JSON.stringify(r.full_name ?? ""), + JSON.stringify(r.student_id_value ?? ""), JSON.stringify(r.email ?? ""), r.status, r.score != null && r.max_score != null ? `${r.score}/${r.max_score}` : "", @@ -99,7 +101,7 @@ function ReportPage() { Attempts {rows.length} of {data.rows.length} shown
- setSearch(e.target.value)} className="max-w-xs" /> + setSearch(e.target.value)} className="max-w-xs" /> setEmail(e.target.value)} + placeholder="teacher@example.com or ABC-00A-000" + value={identifier} + onChange={(e) => setIdentifier(e.target.value)} />
diff --git a/src/routes/signup.tsx b/src/routes/signup.tsx index 477ca66..8056d9a 100644 --- a/src/routes/signup.tsx +++ b/src/routes/signup.tsx @@ -8,6 +8,12 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { toast } from "sonner"; import { AuthShell } from "./login"; +const STUDENT_ID_PATTERN = /^[A-Z]{3}-\d{2}[A-Z]-\d{3}$/i; + +function normalizeStudentId(value: string) { + return value.replace(/\s+/g, "").toUpperCase(); +} + export const Route = createFileRoute("/signup")({ head: () => ({ meta: [{ title: "Sign up — ProctorAI" }] }), component: SignupPage, @@ -18,6 +24,7 @@ function SignupPage() { const [fullName, setFullName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [studentId, setStudentId] = useState(""); const [role, setRole] = useState<"student" | "teacher">("student"); const [loading, setLoading] = useState(false); @@ -27,13 +34,26 @@ function SignupPage() { toast.error("Password must be at least 6 characters."); return; } + + const normalizedStudentId = role === "student" ? normalizeStudentId(studentId) : null; + if (role === "student") { + if (!normalizedStudentId) { + toast.error("Student ID is required for student accounts."); + return; + } + if (!STUDENT_ID_PATTERN.test(normalizedStudentId)) { + toast.error("Student ID must use the format ABC-00A-000."); + return; + } + } + setLoading(true); const { data, error } = await supabase.auth.signUp({ email, password, options: { emailRedirectTo: window.location.origin, - data: { full_name: fullName, role }, + data: { full_name: fullName, role, student_id: normalizedStudentId }, }, }); if (error) { @@ -96,6 +116,19 @@ function SignupPage() { />

At least 6 characters.

+ {role === "student" && ( +
+ + setStudentId(normalizeStudentId(e.target.value))} + /> +

Use the format ABC-00A-000.

+
+ )}
>'student_id', ''); + + INSERT INTO public.profiles (id, email, full_name, student_id) + VALUES ( + NEW.id, + NEW.email, + COALESCE(NEW.raw_user_meta_data->>'full_name', ''), + _student_id + ); + + _role := COALESCE( + NULLIF(NEW.raw_user_meta_data->>'role', '')::public.app_role, + 'student'::public.app_role + ); + + INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, _role); + RETURN NEW; +END; +$$; diff --git a/supabase/migrations/20260721010000_quiz_bank_management.sql b/supabase/migrations/20260721010000_quiz_bank_management.sql new file mode 100644 index 0000000..e13b326 --- /dev/null +++ b/supabase/migrations/20260721010000_quiz_bank_management.sql @@ -0,0 +1,74 @@ +CREATE TABLE public.subjects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE public.quiz_bank_questions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + subject_id uuid NOT NULL REFERENCES public.subjects(id) ON DELETE CASCADE, + question_text text NOT NULL, + option_a text NOT NULL, + option_b text NOT NULL, + option_c text NOT NULL, + option_d text NOT NULL, + correct_option text NOT NULL CHECK (correct_option IN ('A','B','C','D')), + explanation text, + difficulty text NOT NULL DEFAULT 'Medium' CHECK (difficulty IN ('Easy','Medium','Hard')), + marks int NOT NULL DEFAULT 1, + status text NOT NULL DEFAULT 'Draft' CHECK (status IN ('Draft','Published')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE public.quiz_questions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + quiz_id uuid NOT NULL REFERENCES public.quizzes(id) ON DELETE CASCADE, + question_id uuid NOT NULL REFERENCES public.quiz_bank_questions(id) ON DELETE RESTRICT, + display_order int NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (quiz_id, question_id) +); + +CREATE TABLE public.quiz_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + quiz_id uuid NOT NULL REFERENCES public.quizzes(id) ON DELETE CASCADE, + version_name text NOT NULL CHECK (version_name IN ('A','B','C')), + question_order jsonb NOT NULL DEFAULT '[]'::jsonb, + option_order jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (quiz_id, version_name) +); + +ALTER TABLE public.quizzes ADD COLUMN IF NOT EXISTS subject_id uuid REFERENCES public.subjects(id) ON DELETE SET NULL; +ALTER TABLE public.quizzes ADD COLUMN IF NOT EXISTS duration_minutes int; +ALTER TABLE public.quizzes ADD COLUMN IF NOT EXISTS total_marks int; +ALTER TABLE public.quizzes ADD COLUMN IF NOT EXISTS passing_marks int; +ALTER TABLE public.quizzes ADD COLUMN IF NOT EXISTS available_from timestamptz; +ALTER TABLE public.quizzes ADD COLUMN IF NOT EXISTS available_until timestamptz; + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.subjects TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.quiz_bank_questions TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.quiz_questions TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.quiz_versions TO authenticated; + +ALTER TABLE public.subjects ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.quiz_bank_questions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.quiz_questions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.quiz_versions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Teachers manage subjects" ON public.subjects FOR ALL TO authenticated +USING (public.has_role(auth.uid(), 'teacher')) +WITH CHECK (public.has_role(auth.uid(), 'teacher')); + +CREATE POLICY "Teachers manage bank questions" ON public.quiz_bank_questions FOR ALL TO authenticated +USING (public.has_role(auth.uid(), 'teacher')) +WITH CHECK (public.has_role(auth.uid(), 'teacher')); + +CREATE POLICY "Teachers manage quiz links" ON public.quiz_questions FOR ALL TO authenticated +USING (EXISTS (SELECT 1 FROM public.quizzes q WHERE q.id = quiz_questions.quiz_id AND q.teacher_id = auth.uid())) +WITH CHECK (EXISTS (SELECT 1 FROM public.quizzes q WHERE q.id = quiz_questions.quiz_id AND q.teacher_id = auth.uid())); + +CREATE POLICY "Teachers manage quiz versions" ON public.quiz_versions FOR ALL TO authenticated +USING (EXISTS (SELECT 1 FROM public.quizzes q WHERE q.id = quiz_versions.quiz_id AND q.teacher_id = auth.uid())) +WITH CHECK (EXISTS (SELECT 1 FROM public.quizzes q WHERE q.id = quiz_versions.quiz_id AND q.teacher_id = auth.uid())); diff --git a/supabase/seed-quiz-bank.sql b/supabase/seed-quiz-bank.sql new file mode 100644 index 0000000..098df87 --- /dev/null +++ b/supabase/seed-quiz-bank.sql @@ -0,0 +1,107 @@ +INSERT INTO public.subjects (name) VALUES + ('Programming'), + ('Object-Oriented Programming'), + ('Data Structures & Algorithms'), + ('Digital Logic Design'), + ('Database Management Systems'), + ('Computer Networks'), + ('Operating Systems'), + ('Information Security'), + ('Artificial Intelligence'); + +DO $$ +DECLARE + subject_row RECORD; + subject_names text[] := ARRAY[ + 'Programming', + 'Object-Oriented Programming', + 'Data Structures & Algorithms', + 'Digital Logic Design', + 'Database Management Systems', + 'Computer Networks', + 'Operating Systems', + 'Information Security', + 'Artificial Intelligence' + ]; + idx int; + q text; + opts text[]; + correct text; + difficulty text; + marks int; +BEGIN + FOR idx IN 1..9 LOOP + SELECT id INTO subject_row FROM public.subjects WHERE name = subject_names[idx]; + FOR i IN 1..40 LOOP + CASE subject_names[idx] + WHEN 'Programming' THEN + q := 'What does ' || chr(65 + (i % 5)) || ' represent in a typical program?'; + opts := ARRAY['Variable', 'Keyword', 'Operator', 'Loop']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Object-Oriented Programming' THEN + q := 'Which concept describes hiding implementation details?'; + opts := ARRAY['Abstraction', 'Inheritance', 'Polymorphism', 'Encapsulation']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Data Structures & Algorithms' THEN + q := 'Which data structure uses FIFO ordering?'; + opts := ARRAY['Queue', 'Stack', 'Heap', 'Tree']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Digital Logic Design' THEN + q := 'What is the output of an AND gate when one input is 0?'; + opts := ARRAY['0', '1', 'Undefined', 'Both']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Database Management Systems' THEN + q := 'Which keyword is used to remove duplicate rows from a query result?'; + opts := ARRAY['DISTINCT', 'UNIQUE', 'PRIMARY', 'INDEX']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Computer Networks' THEN + q := 'Which protocol is used to resolve domain names?'; + opts := ARRAY['DNS', 'HTTP', 'FTP', 'SMTP']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Operating Systems' THEN + q := 'Which scheduler allocates CPU time to processes?'; + opts := ARRAY['CPU scheduler', 'Memory allocator', 'File system', 'Device driver']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Information Security' THEN + q := 'Which of these is a common method to protect data?'; + opts := ARRAY['Encryption', 'Compression', 'Duplication', 'Deletion']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + WHEN 'Artificial Intelligence' THEN + q := 'Which field focuses on making machines learn from data?'; + opts := ARRAY['Machine Learning', 'Networking', 'Compilers', 'Databases']; + correct := 'A'; + difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; + marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + END CASE; + + INSERT INTO public.quiz_bank_questions ( + subject_id, question_text, option_a, option_b, option_c, option_d, correct_option, explanation, difficulty, marks, status + ) VALUES ( + subject_row.id, + q || ' (' || i || ')', + opts[1], opts[2], opts[3], opts[4], + correct, + 'This is a sample explanation for the question.', + difficulty, + marks, + CASE WHEN i % 3 = 0 THEN 'Published' ELSE 'Draft' END + ); + END LOOP; + END LOOP; +END $$; From 4d974e2d84de6fa5110e007e494989ac2a617a1d Mon Sep 17 00:00:00 2001 From: maxhar518 Date: Sun, 26 Jul 2026 11:18:42 +0500 Subject: [PATCH 2/2] Fixd The quizBank rendering issue by database --- src/lib/quizzes/quiz-data.ts | 75 ++++++ src/lib/quizzes/quiz.functions.ts | 18 +- src/lib/quizzes/student.functions.ts | 33 +-- supabase/.temp/cli-latest | 2 +- supabase/.temp/gotrue-version | 2 +- supabase/.temp/storage-version | 2 +- .../20260726010000_fix_bank_question_rls.sql | 36 +++ ...0_migrate_bank_to_normalized_questions.sql | 69 +++++ supabase/seed-quiz-bank.sql | 252 +++++++++++++----- 9 files changed, 393 insertions(+), 96 deletions(-) create mode 100644 src/lib/quizzes/quiz-data.ts create mode 100644 supabase/migrations/20260726010000_fix_bank_question_rls.sql create mode 100644 supabase/migrations/20260730000000_migrate_bank_to_normalized_questions.sql diff --git a/src/lib/quizzes/quiz-data.ts b/src/lib/quizzes/quiz-data.ts new file mode 100644 index 0000000..1ab8dc9 --- /dev/null +++ b/src/lib/quizzes/quiz-data.ts @@ -0,0 +1,75 @@ +import { supabaseAdmin } from "@/integrations/supabase/client.server"; + +export async function fetchQuizQuestionsAndOptions( + _supabase: any, + quizId: string, + questionSelect = "id, prompt, type, points, position", + optionSelect = "id, question_id, label, position" +) { + const client = supabaseAdmin; + + const { data: linkedRows, error: linkErr } = await client + .from("quiz_questions") + .select("question_id, display_order") + .eq("quiz_id", quizId) + .order("display_order", { ascending: true }); + + if (linkErr) throw new Error(linkErr.message); + + const linkedIds = (linkedRows ?? []).map((row: any) => row.question_id); + let linkedQuestions: any[] = []; + + if (linkedIds.length > 0) { + const { data: rows, error: qErr } = await client + .from("questions") + .select(questionSelect) + .in("id", linkedIds); + + if (qErr) throw new Error(qErr.message); + linkedQuestions = rows ?? []; + } + + const { data: directQuestions, error: directQErr } = await client + .from("questions") + .select(questionSelect) + .eq("quiz_id", quizId) + .order("position", { ascending: true }); + + if (directQErr) throw new Error(directQErr.message); + + const byId = new Map(); + const ordered: Array<{ id: string; position: number }> = []; + + for (const row of linkedRows ?? []) { + const question = linkedQuestions.find((item: any) => item.id === row.question_id); + if (!question || byId.has(question.id)) continue; + const normalizedQuestion = { ...question, position: row.display_order }; + byId.set(question.id, normalizedQuestion); + ordered.push({ id: question.id, position: row.display_order }); + } + + for (const question of directQuestions ?? []) { + if (byId.has(question.id)) continue; + byId.set(question.id, question); + ordered.push({ id: question.id, position: question.position ?? 0 }); + } + + const questions = ordered + .sort((a, b) => a.position - b.position) + .map((entry) => byId.get(entry.id)); + + const questionIds = questions.map((question: any) => question.id); + let options: any[] = []; + if (questionIds.length > 0) { + const { data: rows, error: oErr } = await client + .from("question_options") + .select(optionSelect) + .in("question_id", questionIds) + .order("position", { ascending: true }); + + if (oErr) throw new Error(oErr.message); + options = rows ?? []; + } + + return { questions, options }; +} diff --git a/src/lib/quizzes/quiz.functions.ts b/src/lib/quizzes/quiz.functions.ts index b5b8703..ff365a7 100644 --- a/src/lib/quizzes/quiz.functions.ts +++ b/src/lib/quizzes/quiz.functions.ts @@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; import { quizUpsertSchema, questionUpsertSchema } from "./schemas"; +import { fetchQuizQuestionsAndOptions } from "./quiz-data"; const idSchema = z.object({ quizId: z.string().uuid() }); @@ -28,21 +29,8 @@ export const getQuiz = createServerFn({ method: "POST" }) if (error) throw new Error(error.message); if (!quiz) throw new Error("Quiz not found"); - const { data: questions, error: qErr } = await supabase - .from("questions").select("*").eq("quiz_id", data.quizId) - .order("position", { ascending: true }); - if (qErr) throw new Error(qErr.message); - - const ids = (questions ?? []).map((q) => q.id); - let options: any[] = []; - if (ids.length > 0) { - const { data: opts, error: oErr } = await supabase - .from("question_options").select("*").in("question_id", ids) - .order("position", { ascending: true }); - if (oErr) throw new Error(oErr.message); - options = opts ?? []; - } - return { quiz, questions: questions ?? [], options }; + const { questions, options } = await fetchQuizQuestionsAndOptions(supabase, data.quizId); + return { quiz, questions, options }; }); export const createQuiz = createServerFn({ method: "POST" }) diff --git a/src/lib/quizzes/student.functions.ts b/src/lib/quizzes/student.functions.ts index e4aec5d..e5ea705 100644 --- a/src/lib/quizzes/student.functions.ts +++ b/src/lib/quizzes/student.functions.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; +import { fetchQuizQuestionsAndOptions } from "./quiz-data"; export const enrollByCode = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) @@ -72,18 +73,13 @@ export const getQuizForAttempt = createServerFn({ method: "POST" }) if (error) throw new Error(error.message); if (!quiz || quiz.status !== "published") throw new Error("Quiz unavailable"); - const { data: questions } = await supabase - .from("questions").select("id, prompt, type, points, position") - .eq("quiz_id", data.quizId).order("position"); - const ids = (questions ?? []).map((q) => q.id); - let options: any[] = []; - if (ids.length > 0) { - const { data: opts } = await supabase - .from("question_options").select("id, question_id, label, position") - .in("question_id", ids).order("position"); - options = opts ?? []; - } - return { quiz, questions: questions ?? [], options }; + const { questions, options } = await fetchQuizQuestionsAndOptions( + supabase, + data.quizId, + "id, prompt, type, points, position", + "id, question_id, label, position" + ); + return { quiz, questions, options }; }); export const startAttempt = createServerFn({ method: "POST" }) @@ -142,11 +138,16 @@ export const submitAttempt = createServerFn({ method: "POST" }) } // Grade - const { data: questions } = await supabase - .from("questions").select("id, type, points").eq("quiz_id", data.quizId); - const qIds = (questions ?? []).map((q) => q.id); + const { questions } = await fetchQuizQuestionsAndOptions( + supabase, + data.quizId, + "id, type, points", + "id, question_id, is_correct" + ); + const qIds = (questions ?? []).map((q: any) => q.id); const { data: opts } = await supabase - .from("question_options").select("id, question_id, is_correct") + .from("question_options") + .select("id, question_id, is_correct") .in("question_id", qIds.length > 0 ? qIds : ["00000000-0000-0000-0000-000000000000"]); const correctByQ = new Map>(); (opts ?? []).forEach((o) => { diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index 114c98e..8403edf 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.107.0 \ No newline at end of file +v2.109.1 \ No newline at end of file diff --git a/supabase/.temp/gotrue-version b/supabase/.temp/gotrue-version index c6efa2d..d9dba63 100644 --- a/supabase/.temp/gotrue-version +++ b/supabase/.temp/gotrue-version @@ -1 +1 @@ -v2.190.0 \ No newline at end of file +v2.193.1 \ No newline at end of file diff --git a/supabase/.temp/storage-version b/supabase/.temp/storage-version index 63c5d15..b3655e8 100644 --- a/supabase/.temp/storage-version +++ b/supabase/.temp/storage-version @@ -1 +1 @@ -v1.60.10 \ No newline at end of file +v1.67.15 \ No newline at end of file diff --git a/supabase/migrations/20260726010000_fix_bank_question_rls.sql b/supabase/migrations/20260726010000_fix_bank_question_rls.sql new file mode 100644 index 0000000..b14e7ba --- /dev/null +++ b/supabase/migrations/20260726010000_fix_bank_question_rls.sql @@ -0,0 +1,36 @@ +BEGIN; + +DROP POLICY IF EXISTS "Enrolled students read questions" ON public.questions; +DROP POLICY IF EXISTS "Enrolled students read options" ON public.question_options; + +CREATE POLICY "Enrolled students read questions" ON public.questions FOR SELECT TO authenticated +USING ( + EXISTS ( + SELECT 1 + FROM public.quiz_enrollments e + JOIN public.quizzes q ON q.id = e.quiz_id + LEFT JOIN public.quiz_questions qq ON qq.quiz_id = q.id + WHERE e.student_id = auth.uid() + AND q.status = 'published' + AND ( + (questions.quiz_id IS NOT NULL AND q.id = questions.quiz_id) + OR (questions.quiz_id IS NULL AND qq.question_id = questions.id) + ) + ) +); + +CREATE POLICY "Enrolled students read options" ON public.question_options FOR SELECT TO authenticated +USING ( + EXISTS ( + SELECT 1 + FROM public.questions qu + LEFT JOIN public.quiz_questions qq ON qq.question_id = qu.id + JOIN public.quizzes q ON q.id = COALESCE(qu.quiz_id, qq.quiz_id) + JOIN public.quiz_enrollments e ON e.quiz_id = q.id + WHERE qu.id = question_options.question_id + AND e.student_id = auth.uid() + AND q.status = 'published' + ) +); + +COMMIT; diff --git a/supabase/migrations/20260730000000_migrate_bank_to_normalized_questions.sql b/supabase/migrations/20260730000000_migrate_bank_to_normalized_questions.sql new file mode 100644 index 0000000..1cf0a1f --- /dev/null +++ b/supabase/migrations/20260730000000_migrate_bank_to_normalized_questions.sql @@ -0,0 +1,69 @@ +-- Migration: migrate quiz_bank_questions -> questions + question_options +BEGIN; + +-- 1) Make questions support bank (allow quiz_id NULL) and subject_id +ALTER TABLE public.questions ALTER COLUMN quiz_id DROP NOT NULL; +ALTER TABLE public.questions ADD COLUMN IF NOT EXISTS subject_id uuid REFERENCES public.subjects(id) ON DELETE SET NULL; + +-- 2) Copy quiz_bank_questions into questions (reuse ids to preserve links) +INSERT INTO public.questions (id, quiz_id, prompt, type, points, position, created_at, updated_at, subject_id) +SELECT id, NULL, question_text, 'single', marks, 0, created_at, updated_at, subject_id +FROM public.quiz_bank_questions; + +-- 3) Migrate options from option_a..d into question_options +INSERT INTO public.question_options (id, question_id, label, is_correct, position, created_at) +SELECT gen_random_uuid(), qb.id, qb.option_a, (qb.correct_option = 'A'), 1, qb.created_at FROM public.quiz_bank_questions qb +UNION ALL +SELECT gen_random_uuid(), qb.id, qb.option_b, (qb.correct_option = 'B'), 2, qb.created_at FROM public.quiz_bank_questions qb +UNION ALL +SELECT gen_random_uuid(), qb.id, qb.option_c, (qb.correct_option = 'C'), 3, qb.created_at FROM public.quiz_bank_questions qb +UNION ALL +SELECT gen_random_uuid(), qb.id, qb.option_d, (qb.correct_option = 'D'), 4, qb.created_at FROM public.quiz_bank_questions qb; + +-- 4) Redirect quiz_questions.question_id FK to reference public.questions +ALTER TABLE public.quiz_questions DROP CONSTRAINT IF EXISTS quiz_questions_question_id_fkey; +ALTER TABLE public.quiz_questions ADD CONSTRAINT quiz_questions_question_id_fkey FOREIGN KEY (question_id) REFERENCES public.questions(id) ON DELETE RESTRICT; + +-- 5) Remove the old table (we'll recreate a compatibility view below) +DROP TABLE public.quiz_bank_questions; + +-- 6) Create a compatibility view so existing queries selecting option_a..option_d still work +CREATE OR REPLACE VIEW public.quiz_bank_questions AS +SELECT + q.id, + q.subject_id, + q.prompt AS question_text, + MAX(CASE WHEN qo.position = 1 THEN qo.label END) AS option_a, + MAX(CASE WHEN qo.position = 2 THEN qo.label END) AS option_b, + MAX(CASE WHEN qo.position = 3 THEN qo.label END) AS option_c, + MAX(CASE WHEN qo.position = 4 THEN qo.label END) AS option_d, + COALESCE( + MAX(CASE WHEN qo.position = 1 AND qo.is_correct THEN 'A' END), + MAX(CASE WHEN qo.position = 2 AND qo.is_correct THEN 'B' END), + MAX(CASE WHEN qo.position = 3 AND qo.is_correct THEN 'C' END), + MAX(CASE WHEN qo.position = 4 AND qo.is_correct THEN 'D' END) + ) AS correct_option, + NULL::text AS explanation, + q.type AS difficulty, + q.points AS marks, + 'Published'::text AS status, + q.created_at, + q.updated_at +FROM public.questions q +LEFT JOIN public.question_options qo ON qo.question_id = q.id +WHERE q.quiz_id IS NULL +GROUP BY q.id, q.subject_id, q.prompt, q.type, q.points, q.created_at, q.updated_at; + +-- 7) Grant select on view to authenticated (keep previous grants) +GRANT SELECT ON public.quiz_bank_questions TO authenticated; + +-- 8) Add policy to allow teachers to manage bank questions (questions where quiz_id IS NULL) +-- (this complements the existing question policies) +DROP POLICY IF EXISTS "Teachers manage bank questions" ON public.questions; +CREATE POLICY "Teachers manage bank questions" ON public.questions FOR ALL TO authenticated +USING (public.has_role(auth.uid(), 'teacher') AND quiz_id IS NULL) +WITH CHECK (public.has_role(auth.uid(), 'teacher') AND quiz_id IS NULL); + +COMMIT; + +-- End migration diff --git a/supabase/seed-quiz-bank.sql b/supabase/seed-quiz-bank.sql index 098df87..67c4a31 100644 --- a/supabase/seed-quiz-bank.sql +++ b/supabase/seed-quiz-bank.sql @@ -1,4 +1,6 @@ -INSERT INTO public.subjects (name) VALUES +-- Seed subjects (idempotent) +INSERT INTO public.subjects (name) +VALUES ('Programming'), ('Object-Oriented Programming'), ('Data Structures & Algorithms'), @@ -7,8 +9,25 @@ INSERT INTO public.subjects (name) VALUES ('Computer Networks'), ('Operating Systems'), ('Information Security'), - ('Artificial Intelligence'); + ('Artificial Intelligence') +ON CONFLICT (name) DO NOTHING; +-- +-- This block inserts up to 10 distinct questions per subject. +-- It is idempotent for re-population: it deletes existing questions for +-- the target subjects before inserting the new batch. +-- The `quiz_bank_questions` table schema (from migrations) is: +-- id uuid PRIMARY KEY DEFAULT gen_random_uuid() +-- subject_id uuid NOT NULL REFERENCES public.subjects(id) ON DELETE CASCADE +-- question_text text NOT NULL +-- option_a..option_d text NOT NULL +-- correct_option text NOT NULL CHECK (correct_option IN ('A','B','C','D')) +-- explanation text +-- difficulty text NOT NULL DEFAULT 'Medium' CHECK (difficulty IN ('Easy','Medium','Hard')) +-- marks int NOT NULL DEFAULT 1 +-- status text NOT NULL DEFAULT 'Draft' CHECK (status IN ('Draft','Published')) +-- created_at/updated_at timestamptz NOT NULL DEFAULT now() +-- DO $$ DECLARE subject_row RECORD; @@ -24,84 +43,193 @@ DECLARE 'Artificial Intelligence' ]; idx int; + j int; q text; - opts text[]; + opts text[4]; correct text; difficulty text; marks int; + templates text[]; BEGIN - FOR idx IN 1..9 LOOP + FOR idx IN 1..array_length(subject_names,1) LOOP SELECT id INTO subject_row FROM public.subjects WHERE name = subject_names[idx]; - FOR i IN 1..40 LOOP + IF subject_row.id IS NULL THEN + RAISE NOTICE 'Subject % not found, skipping', subject_names[idx]; + CONTINUE; + END IF; + + -- remove existing seeded bank questions for this subject so script can be re-run + -- Only delete questions that are not linked to any quiz through quiz_questions. + DELETE FROM public.question_options + WHERE question_id IN ( + SELECT q.id + FROM public.questions q + LEFT JOIN public.quiz_questions qq ON qq.question_id = q.id + WHERE q.subject_id = subject_row.id + AND q.quiz_id IS NULL + AND qq.id IS NULL + ); + + DELETE FROM public.questions + WHERE id IN ( + SELECT q.id + FROM public.questions q + LEFT JOIN public.quiz_questions qq ON qq.question_id = q.id + WHERE q.subject_id = subject_row.id + AND q.quiz_id IS NULL + AND qq.id IS NULL + ); + + FOR j IN 1..10 LOOP + -- choose per-subject templates and options (rotate using j) CASE subject_names[idx] WHEN 'Programming' THEN - q := 'What does ' || chr(65 + (i % 5)) || ' represent in a typical program?'; - opts := ARRAY['Variable', 'Keyword', 'Operator', 'Loop']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'What does recursion typically involve?', + 'Which keyword is used to define a function in many languages?', + 'Which construct repeats a block until a condition is false?', + 'What data type would hold true/false values?', + 'Which operator is commonly used for assignment?' + ]; + opts[1] := 'A function calling itself'; + opts[2] := 'A loop that never ends'; + opts[3] := 'A data structure'; + opts[4] := 'A compiler phase'; WHEN 'Object-Oriented Programming' THEN - q := 'Which concept describes hiding implementation details?'; - opts := ARRAY['Abstraction', 'Inheritance', 'Polymorphism', 'Encapsulation']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which OOP principle lets objects expose behavior while hiding details?', + 'Which principle allows a class to take multiple forms?', + 'Which mechanism lets a class derive from another?', + 'What term describes bundling data with methods?', + 'Which keyword is used to create an instance of a class in many languages?' + ]; + opts[1] := 'Abstraction'; + opts[2] := 'Polymorphism'; + opts[3] := 'Inheritance'; + opts[4] := 'Encapsulation'; WHEN 'Data Structures & Algorithms' THEN - q := 'Which data structure uses FIFO ordering?'; - opts := ARRAY['Queue', 'Stack', 'Heap', 'Tree']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which data structure uses FIFO ordering?', + 'Which data structure uses LIFO ordering?', + 'Which algorithmic technique divides problem into subproblems?', + 'Which structure is optimal for LRU cache?', + 'Which traversal visits left, node, then right?' + ]; + opts[1] := 'Queue'; + opts[2] := 'Stack'; + opts[3] := 'Heap'; + opts[4] := 'Tree'; WHEN 'Digital Logic Design' THEN - q := 'What is the output of an AND gate when one input is 0?'; - opts := ARRAY['0', '1', 'Undefined', 'Both']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'What is the output of an AND gate when one input is 0?', + 'Which gate outputs 1 only when inputs differ?', + 'Which representation uses 0/1 for logic?', + 'Which component stores a bit of state?', + 'What does a multiplexer do?' + ]; + opts[1] := '0'; + opts[2] := '1'; + opts[3] := 'Undefined'; + opts[4] := 'Both'; WHEN 'Database Management Systems' THEN - q := 'Which keyword is used to remove duplicate rows from a query result?'; - opts := ARRAY['DISTINCT', 'UNIQUE', 'PRIMARY', 'INDEX']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which keyword removes duplicate rows from query results?', + 'Which command retrieves rows from a table?', + 'Which clause filters rows returned by SELECT?', + 'Which constraint enforces uniqueness?', + 'Which operation joins two tables?' + ]; + opts[1] := 'DISTINCT'; + opts[2] := 'SELECT'; + opts[3] := 'WHERE'; + opts[4] := 'UNIQUE'; WHEN 'Computer Networks' THEN - q := 'Which protocol is used to resolve domain names?'; - opts := ARRAY['DNS', 'HTTP', 'FTP', 'SMTP']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which protocol is used to resolve domain names?', + 'Which protocol is used to transfer web pages?', + 'Which layer in OSI handles routing?', + 'Which protocol is used for reliable byte streams?', + 'Which address type is used to identify a host on a local network?' + ]; + opts[1] := 'DNS'; + opts[2] := 'HTTP'; + opts[3] := 'IP'; + opts[4] := 'MAC'; WHEN 'Operating Systems' THEN - q := 'Which scheduler allocates CPU time to processes?'; - opts := ARRAY['CPU scheduler', 'Memory allocator', 'File system', 'Device driver']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which scheduler is responsible for choosing the next process to run?', + 'Which structure keeps track of open files?', + 'Which mechanism isolates processes memory?', + 'Which component manages hardware interrupts?', + 'Which policy decides which page to evict?' + ]; + opts[1] := 'CPU scheduler'; + opts[2] := 'File table'; + opts[3] := 'Virtual memory'; + opts[4] := 'Interrupt handler'; WHEN 'Information Security' THEN - q := 'Which of these is a common method to protect data?'; - opts := ARRAY['Encryption', 'Compression', 'Duplication', 'Deletion']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which of these is a common method to protect data?', + 'Which method verifies user identity?', + 'Which practice reduces attack surface by fixing bugs?', + 'Which technique makes data unreadable without a key?', + 'Which model separates duties to reduce fraud?' + ]; + opts[1] := 'Encryption'; + opts[2] := 'Authentication'; + opts[3] := 'Patching'; + opts[4] := 'Authorization'; WHEN 'Artificial Intelligence' THEN - q := 'Which field focuses on making machines learn from data?'; - opts := ARRAY['Machine Learning', 'Networking', 'Compilers', 'Databases']; - correct := 'A'; - difficulty := CASE (i % 3) WHEN 0 THEN 'Hard' WHEN 1 THEN 'Medium' ELSE 'Easy' END; - marks := CASE WHEN difficulty = 'Hard' THEN 3 WHEN difficulty = 'Medium' THEN 2 ELSE 1 END; + templates := ARRAY[ + 'Which field focuses on making machines learn from data?', + 'Which algorithm family is used for classification/regression?', + 'Which technique uses neural layers to learn representations?', + 'Which area studies search and planning?', + 'Which subfield focuses on understanding language?' + ]; + opts[1] := 'Machine Learning'; + opts[2] := 'Decision Trees'; + opts[3] := 'Deep Learning'; + opts[4] := 'Search'; + ELSE + templates := ARRAY['Sample question?']; + opts[1] := 'Option A'; opts[2] := 'Option B'; opts[3] := 'Option C'; opts[4] := 'Option D'; END CASE; - INSERT INTO public.quiz_bank_questions ( - subject_id, question_text, option_a, option_b, option_c, option_d, correct_option, explanation, difficulty, marks, status - ) VALUES ( - subject_row.id, - q || ' (' || i || ')', - opts[1], opts[2], opts[3], opts[4], - correct, - 'This is a sample explanation for the question.', - difficulty, - marks, - CASE WHEN i % 3 = 0 THEN 'Published' ELSE 'Draft' END - ); + -- pick a template rotating through the template array + q := templates[((j - 1) % array_length(templates,1)) + 1] || ' (variant ' || j || ')'; + + -- choose correct answer letter rotating A..D + correct := chr(65 + ((j - 1) % 4)); + + -- difficulty pattern: first 4 Easy, next 4 Medium, last 2 Hard (for 10 questions) + IF j <= 4 THEN + difficulty := 'Easy'; + ELSIF j <= 8 THEN + difficulty := 'Medium'; + ELSE + difficulty := 'Hard'; + END IF; + + marks := CASE difficulty WHEN 'Hard' THEN 3 WHEN 'Medium' THEN 2 ELSE 1 END; + + -- insert normalized question (bank entry: quiz_id = NULL) + DECLARE new_q_id uuid; + BEGIN + INSERT INTO public.questions (subject_id, quiz_id, prompt, type, points, position, created_at, updated_at) + VALUES (subject_row.id, NULL, q, 'single', marks, 0, now(), now()) + RETURNING id INTO new_q_id; + + -- insert options + INSERT INTO public.question_options (question_id, label, is_correct, position, created_at) + VALUES + (new_q_id, opts[1], (correct = 'A'), 1, now()), + (new_q_id, opts[2], (correct = 'B'), 2, now()), + (new_q_id, opts[3], (correct = 'C'), 3, now()), + (new_q_id, opts[4], (correct = 'D'), 4, now()); + END; END LOOP; END LOOP; END $$; + +-- End of seed file