| import express from "express"; |
| import path from "path"; |
| import cors from "cors"; |
| import cookieParser from "cookie-parser"; |
| import jwt from "jsonwebtoken"; |
| import bcrypt from "bcryptjs"; |
| import admin from "firebase-admin"; |
| import { getFirestore, FieldValue, FieldPath } from "firebase-admin/firestore"; |
| import { createServer as createViteServer } from "vite"; |
| import fs from "fs"; |
| import crypto from "crypto"; |
|
|
| |
| const configPath = path.join(process.cwd(), 'firebase-applet-config.json'); |
| const firebaseConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); |
|
|
| const appAdmin = admin.initializeApp({ |
| projectId: firebaseConfig.projectId, |
| }); |
|
|
| const db = firebaseConfig.firestoreDatabaseId |
| ? getFirestore(appAdmin, firebaseConfig.firestoreDatabaseId) |
| : getFirestore(appAdmin); |
|
|
| const JWT_SECRET = process.env.JWT_SECRET || "exam-forge-secret-key-2024"; |
|
|
| const app = express(); |
| app.use(cors()); |
| app.use(express.json()); |
| app.use(cookieParser()); |
|
|
| |
| const USERS_FILE = path.join(process.cwd(), 'users-local-db.json'); |
| const COUNTERS_FILE = path.join(process.cwd(), 'counters-local-db.json'); |
| const AI_LOGS_FILE = path.join(process.cwd(), 'ai-logs-local-db.json'); |
| const EXAMS_FILE = path.join(process.cwd(), 'exams-local-db.json'); |
| const QUESTIONS_FILE = path.join(process.cwd(), 'questions-local-db.json'); |
| const GROUPS_FILE = path.join(process.cwd(), 'groups-local-db.json'); |
| const ATTEMPTS_FILE = path.join(process.cwd(), 'attempts-local-db.json'); |
|
|
| function loadLocalUsers(): any[] { |
| try { |
| if (fs.existsSync(USERS_FILE)) { |
| return JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local users:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalUsers(users: any[]) { |
| try { |
| fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local users:", err); |
| } |
| } |
|
|
| function loadLocalExams(): any[] { |
| try { |
| if (fs.existsSync(EXAMS_FILE)) { |
| return JSON.parse(fs.readFileSync(EXAMS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local exams:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalExams(exams: any[]) { |
| try { |
| fs.writeFileSync(EXAMS_FILE, JSON.stringify(exams, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local exams:", err); |
| } |
| } |
|
|
| function loadLocalQuestions(): any[] { |
| try { |
| if (fs.existsSync(QUESTIONS_FILE)) { |
| return JSON.parse(fs.readFileSync(QUESTIONS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local questions:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalQuestions(questions: any[]) { |
| try { |
| fs.writeFileSync(QUESTIONS_FILE, JSON.stringify(questions, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local questions:", err); |
| } |
| } |
|
|
| function loadLocalGroups(): any[] { |
| try { |
| if (fs.existsSync(GROUPS_FILE)) { |
| return JSON.parse(fs.readFileSync(GROUPS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local groups:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalGroups(groups: any[]) { |
| try { |
| fs.writeFileSync(GROUPS_FILE, JSON.stringify(groups, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local groups:", err); |
| } |
| } |
|
|
| function loadLocalAttempts(): any[] { |
| try { |
| if (fs.existsSync(ATTEMPTS_FILE)) { |
| return JSON.parse(fs.readFileSync(ATTEMPTS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local attempts:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalAttempts(attempts: any[]) { |
| try { |
| fs.writeFileSync(ATTEMPTS_FILE, JSON.stringify(attempts, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local attempts:", err); |
| } |
| } |
|
|
| |
| const SESSIONS_FILE = path.join(process.cwd(), 'sessions-local-db.json'); |
|
|
| function loadLocalSessions(): any[] { |
| try { |
| if (fs.existsSync(SESSIONS_FILE)) { |
| return JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local sessions:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalSessions(sessions: any[]) { |
| try { |
| fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local sessions:", err); |
| } |
| } |
|
|
| |
| const AUDIT_LOGS_FILE = path.join(process.cwd(), 'audit-logs-local-db.json'); |
|
|
| function loadLocalAudits(): any[] { |
| try { |
| if (fs.existsSync(AUDIT_LOGS_FILE)) { |
| return JSON.parse(fs.readFileSync(AUDIT_LOGS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local audits:", err); |
| } |
| return []; |
| } |
|
|
| function saveLocalAudits(audits: any[]) { |
| try { |
| fs.writeFileSync(AUDIT_LOGS_FILE, JSON.stringify(audits, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local audits:", err); |
| } |
| } |
|
|
| async function logAudit(action: string, actor: string, details: string) { |
| try { |
| const audits = loadLocalAudits(); |
| const entry = { |
| id: `AUD-${Date.now()}-${Math.random().toString(36).substr(2, 5).toUpperCase()}`, |
| action, |
| actor, |
| details, |
| timestamp: new Date().toISOString() |
| }; |
| audits.push(entry); |
| saveLocalAudits(audits); |
| } catch (err) { |
| console.error("Failed to log audit:", err); |
| } |
| } |
|
|
| |
| |
| const userRequestTimestamps: Record<string, number[]> = {}; |
| const orgRequestTimestamps: Record<string, number[]> = {}; |
|
|
| interface QueuedTask { |
| req: any; |
| res: any; |
| next: any; |
| } |
| const requestQueue: QueuedTask[] = []; |
|
|
| function checkAndProcessQueue() { |
| if (requestQueue.length === 0) return; |
|
|
| const now = Date.now(); |
| const minuteAgo = now - 60 * 1000; |
| const hourAgo = now - 60 * 60 * 1000; |
|
|
| const task = requestQueue[0]; |
| const ip = task.req.ip || 'unknown-ip'; |
| const userId = task.req.cookies?.jwt_session |
| ? (jwt.decode(task.req.cookies.jwt_session) as any)?.uid || ip |
| : ip; |
| const orgId = task.req.body?.organization_id || 'org-default'; |
|
|
| if (!userRequestTimestamps[userId]) userRequestTimestamps[userId] = []; |
| userRequestTimestamps[userId] = userRequestTimestamps[userId].filter(t => t > minuteAgo); |
|
|
| if (!orgRequestTimestamps[orgId]) orgRequestTimestamps[orgId] = []; |
| orgRequestTimestamps[orgId] = orgRequestTimestamps[orgId].filter(t => t > hourAgo); |
|
|
| if (userRequestTimestamps[userId].length < 60 && orgRequestTimestamps[orgId].length < 500) { |
| userRequestTimestamps[userId].push(now); |
| orgRequestTimestamps[orgId].push(now); |
| requestQueue.shift(); |
| task.next(); |
| setTimeout(checkAndProcessQueue, 50); |
| } |
| } |
|
|
| function nvidiaRateLimiter(req: any, res: any, next: any) { |
| const ip = req.ip || 'unknown-ip'; |
| const userId = req.cookies?.jwt_session |
| ? (jwt.decode(req.cookies.jwt_session) as any)?.uid || ip |
| : ip; |
| const orgId = req.body?.organization_id || 'org-default'; |
| const now = Date.now(); |
| const minuteAgo = now - 60 * 1000; |
| const hourAgo = now - 60 * 60 * 1000; |
|
|
| if (!userRequestTimestamps[userId]) userRequestTimestamps[userId] = []; |
| userRequestTimestamps[userId] = userRequestTimestamps[userId].filter(t => t > minuteAgo); |
|
|
| if (!orgRequestTimestamps[orgId]) orgRequestTimestamps[orgId] = []; |
| orgRequestTimestamps[orgId] = orgRequestTimestamps[orgId].filter(t => t > hourAgo); |
|
|
| if (userRequestTimestamps[userId].length < 60 && orgRequestTimestamps[orgId].length < 500) { |
| userRequestTimestamps[userId].push(now); |
| orgRequestTimestamps[orgId].push(now); |
| return next(); |
| } |
|
|
| |
| if (requestQueue.length >= 100) { |
| return res.status(429).json({ error: "AI gateway limit exceeded and request queue is full. Try again shortly." }); |
| } |
|
|
| requestQueue.push({ req, res, next }); |
| setTimeout(checkAndProcessQueue, 1000); |
| } |
|
|
| function loadLocalCounters(): Record<string, number> { |
| try { |
| if (fs.existsSync(COUNTERS_FILE)) { |
| return JSON.parse(fs.readFileSync(COUNTERS_FILE, 'utf-8')); |
| } |
| } catch (err) { |
| console.error("Failed to load local counters:", err); |
| } |
| return {}; |
| } |
|
|
| function saveLocalCounters(counters: Record<string, number>) { |
| try { |
| fs.writeFileSync(COUNTERS_FILE, JSON.stringify(counters, null, 2), 'utf-8'); |
| } catch (err) { |
| console.error("Failed to save local counters:", err); |
| } |
| } |
|
|
| async function getNextNumber(prefix: string): Promise<string> { |
| const counters = loadLocalCounters(); |
| const nextNum = (counters[prefix] || 0) + 1; |
| counters[prefix] = nextNum; |
| saveLocalCounters(counters); |
| return nextNum.toString().padStart(3, '0'); |
| } |
|
|
| function generateShortName(name: string): string { |
| const parts = name.trim().split(/\s+/); |
| if (parts.length === 1) return parts[0].slice(0, 3).toLowerCase(); |
| |
| return parts.slice(0, 3).map(p => p[0]).join('').toLowerCase(); |
| } |
|
|
| |
| function getTransitionedStatus(exam: any): string { |
| if (['draft', 'paused', 'archived'].includes(exam.status)) { |
| return exam.status; |
| } |
| const now = Date.now(); |
| const startTime = exam.startTime ? new Date(exam.startTime).getTime() : null; |
| const endTime = exam.endTime ? new Date(exam.endTime).getTime() : null; |
| const durationMs = (exam.duration || 60) * 60 * 1000; |
|
|
| if (startTime) { |
| if (now < startTime) { |
| return 'scheduled'; |
| } |
| const calculatedEndTime = endTime || (startTime + durationMs); |
| if (now > calculatedEndTime) { |
| return 'completed'; |
| } |
| return 'active'; |
| } |
|
|
| if (exam.status === 'published' || exam.status === 'active') { |
| return 'active'; |
| } |
|
|
| return exam.status || 'draft'; |
| } |
|
|
| |
| function generateExamId(existingIds: string[]): string { |
| const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; |
| const today = new Date(); |
| const yyyy = today.getFullYear(); |
| const mm = String(today.getMonth() + 1).padStart(2, '0'); |
| const dd = String(today.getDate()).padStart(2, '0'); |
| const dateStr = `${yyyy}${mm}${dd}`; |
|
|
| while (true) { |
| let entropy = ''; |
| for (let i = 0; i < 6; i++) { |
| entropy += chars.charAt(Math.floor(Math.random() * chars.length)); |
| } |
| const examId = `EXM-${dateStr}-${entropy}`; |
| if (!existingIds.includes(examId)) { |
| return examId; |
| } |
| } |
| } |
|
|
| |
| async function loadUsersFromDb(): Promise<any[]> { |
| const local = loadLocalUsers(); |
| try { |
| const snapshot = await db.collection("users").get(); |
| if (snapshot.empty) { |
| if (local.length > 0) { |
| const batch = db.batch(); |
| local.forEach(u => batch.set(db.collection("users").doc(u.id), u)); |
| await batch.commit(); |
| } |
| return local; |
| } |
| const items: any[] = []; |
| snapshot.forEach(doc => items.push({ ...doc.data(), id: doc.id })); |
| saveLocalUsers(items); |
| return items; |
| } catch (err) { |
| console.error("Firestore users loading failed, fallback to local:", err); |
| return local; |
| } |
| } |
|
|
| async function saveUserToDb(user: any) { |
| const users = loadLocalUsers(); |
| const idx = users.findIndex(u => u.id === user.id); |
| if (idx !== -1) users[idx] = user; |
| else users.push(user); |
| saveLocalUsers(users); |
| try { |
| await db.collection("users").doc(user.id).set(user); |
| } catch (err) { |
| console.error("Firestore user saving failed:", err); |
| } |
| } |
|
|
| async function loadExamsFromDb(): Promise<any[]> { |
| const local = loadLocalExams(); |
| try { |
| const snapshot = await db.collection("exams").get(); |
| if (snapshot.empty) { |
| if (local.length > 0) { |
| const batch = db.batch(); |
| local.forEach(e => batch.set(db.collection("exams").doc(e.id), e)); |
| await batch.commit(); |
| } |
| return local; |
| } |
| const items: any[] = []; |
| snapshot.forEach(doc => items.push({ ...doc.data(), id: doc.id })); |
| |
| |
| const transitioned = items.map(e => { |
| const original = e.status; |
| const status = getTransitionedStatus(e); |
| if (status !== original) { |
| e.status = status; |
| e.updatedAt = new Date().toISOString(); |
| db.collection("exams").doc(e.id).update({ status, updatedAt: e.updatedAt }).catch(() => {}); |
| } |
| return e; |
| }); |
| saveLocalExams(transitioned); |
| return transitioned; |
| } catch (err) { |
| console.error("Firestore exams loading failed, fallback to local:", err); |
| const transitioned = local.map(e => { |
| const original = e.status; |
| const status = getTransitionedStatus(e); |
| if (status !== original) { |
| e.status = status; |
| e.updatedAt = new Date().toISOString(); |
| } |
| return e; |
| }); |
| saveLocalExams(transitioned); |
| return transitioned; |
| } |
| } |
|
|
| async function saveExamToDb(exam: any) { |
| const exams = loadLocalExams(); |
| const idx = exams.findIndex(e => e.id === exam.id); |
| if (idx !== -1) exams[idx] = exam; |
| else exams.push(exam); |
| saveLocalExams(exams); |
| try { |
| await db.collection("exams").doc(exam.id).set(exam); |
| } catch (err) { |
| console.error("Firestore exam saving failed:", err); |
| } |
| } |
|
|
| async function loadQuestionsFromDb(): Promise<any[]> { |
| const local = loadLocalQuestions(); |
| try { |
| const snapshot = await db.collection("questions").get(); |
| if (snapshot.empty) { |
| if (local.length > 0) { |
| const batch = db.batch(); |
| local.forEach(q => batch.set(db.collection("questions").doc(q.id), q)); |
| await batch.commit(); |
| } |
| return local; |
| } |
| const items: any[] = []; |
| snapshot.forEach(doc => items.push({ ...doc.data(), id: doc.id })); |
| saveLocalQuestions(items); |
| return items; |
| } catch (err) { |
| console.error("Firestore questions loading failed, fallback to local:", err); |
| return local; |
| } |
| } |
|
|
| async function saveQuestionToDb(question: any) { |
| const questions = loadLocalQuestions(); |
| const idx = questions.findIndex(q => q.id === question.id); |
| if (idx !== -1) questions[idx] = question; |
| else questions.push(question); |
| saveLocalQuestions(questions); |
| try { |
| await db.collection("questions").doc(question.id).set(question); |
| } catch (err) { |
| console.error("Firestore question saving failed:", err); |
| } |
| } |
|
|
| async function loadGroupsFromDb(): Promise<any[]> { |
| const local = loadLocalGroups(); |
| try { |
| const snapshot = await db.collection("groups").get(); |
| if (snapshot.empty) { |
| if (local.length > 0) { |
| const batch = db.batch(); |
| local.forEach(g => batch.set(db.collection("groups").doc(g.id), g)); |
| await batch.commit(); |
| } |
| return local; |
| } |
| const items: any[] = []; |
| snapshot.forEach(doc => items.push({ ...doc.data(), id: doc.id })); |
| saveLocalGroups(items); |
| return items; |
| } catch (err) { |
| console.error("Firestore groups loading failed, fallback to local:", err); |
| return local; |
| } |
| } |
|
|
| async function saveGroupToDb(group: any) { |
| const groups = loadLocalGroups(); |
| const idx = groups.findIndex(g => g.id === group.id); |
| if (idx !== -1) groups[idx] = group; |
| else groups.push(group); |
| saveLocalGroups(groups); |
| try { |
| await db.collection("groups").doc(group.id).set(group); |
| } catch (err) { |
| console.error("Firestore group saving failed:", err); |
| } |
| } |
|
|
| async function loadAttemptsFromDb(): Promise<any[]> { |
| const local = loadLocalAttempts(); |
| try { |
| const snapshot = await db.collection("attempts").get(); |
| if (snapshot.empty) { |
| if (local.length > 0) { |
| const batch = db.batch(); |
| local.forEach(a => batch.set(db.collection("attempts").doc(a.id), a)); |
| await batch.commit(); |
| } |
| return local; |
| } |
| const items: any[] = []; |
| snapshot.forEach(doc => items.push({ ...doc.data(), id: doc.id })); |
| saveLocalAttempts(items); |
| return items; |
| } catch (err) { |
| console.error("Firestore attempts loading failed, fallback to local:", err); |
| return local; |
| } |
| } |
|
|
| async function saveAttemptToDb(attempt: any) { |
| const attempts = loadLocalAttempts(); |
| const idx = attempts.findIndex(a => a.id === attempt.id); |
| if (idx !== -1) attempts[idx] = attempt; |
| else attempts.push(attempt); |
| saveLocalAttempts(attempts); |
| try { |
| await db.collection("attempts").doc(attempt.id).set(attempt); |
| } catch (err) { |
| console.error("Firestore attempt saving failed:", err); |
| } |
| } |
|
|
| const SUBJECT_CODES: Record<string, string> = { |
| "Mathematics": "mth", |
| "English": "eng", |
| "Physics": "phy", |
| "Chemistry": "chm", |
| "Biology": "bio", |
| "ICT": "ict" |
| }; |
|
|
| |
| function setJwtCookie(res: any, uid: string, role: string) { |
| const sessionToken = jwt.sign( |
| { uid, role }, |
| JWT_SECRET, |
| { expiresIn: '24h' } |
| ); |
|
|
| res.cookie('jwt_session', sessionToken, { |
| httpOnly: true, |
| secure: process.env.NODE_ENV === "production", |
| sameSite: 'strict', |
| maxAge: 24 * 60 * 60 * 1000 |
| }); |
| } |
|
|
| |
| async function getAuthenticatedUserId(req: any): Promise<string> { |
| const tokenCookie = req.cookies?.jwt_session; |
| if (tokenCookie) { |
| try { |
| const decoded = jwt.verify(tokenCookie, JWT_SECRET) as any; |
| if (decoded && decoded.uid) { |
| return decoded.uid; |
| } |
| } catch (err) { |
| console.warn("Secure JWT Cookie verification failed:", err); |
| } |
| } |
|
|
| |
| const authHeader = req.headers.authorization; |
| if (!authHeader) throw new Error("Unauthorized: Missing credential details"); |
|
|
| const idToken = authHeader.split(' ')[1]; |
| try { |
| const decodedToken = await admin.auth().verifyIdToken(idToken); |
| return decodedToken.uid; |
| } catch (error) { |
| |
| try { |
| const decoded = jwt.verify(idToken, JWT_SECRET) as any; |
| if (decoded && decoded.uid) { |
| return decoded.uid; |
| } |
| } catch (jwtErr) { |
| console.warn("Local fallback JWT verification also failed:", jwtErr); |
| } |
| throw new Error("Unauthorized: Invalid authorization credentials"); |
| } |
| } |
|
|
| |
| app.post("/api/auth/register/student", async (req, res) => { |
| try { |
| const { fullName, class: className, department, pin } = req.body; |
| |
| const shortName = generateShortName(fullName); |
| const classPrefix = className.toLowerCase().replace(/\s+/g, '').replace(/[^-a-z0-9]/g, ''); |
| const prefix = `${classPrefix}-${shortName}-`; |
| const num = await getNextNumber(prefix); |
| const studentId = `${prefix}${num}`; |
| |
| const pinHash = await bcrypt.hash(pin, 10); |
| |
| const userProfile = { |
| id: studentId, |
| fullName, |
| role: 'student', |
| class: className, |
| department: department || null, |
| pinHash, |
| createdAt: new Date().toISOString(), |
| examHistory: [] |
| }; |
| |
| const users = loadLocalUsers(); |
| users.push(userProfile); |
| saveLocalUsers(users); |
| |
| const customToken = jwt.sign({ uid: studentId }, JWT_SECRET); |
| setJwtCookie(res, studentId, 'student'); |
| |
| |
| const { pinHash: _, ...publicProfile } = userProfile; |
| res.json({ customToken, user: publicProfile }); |
| } catch (error: any) { |
| console.error("Register Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/auth/register/teacher", async (req, res) => { |
| try { |
| const { fullName, subject, position, classesManaged, pin } = req.body; |
| |
| const initials = fullName.trim().split(/\s+/).map(p => p[0]).join('').toLowerCase().slice(0, 2); |
| const subCode = SUBJECT_CODES[subject] || subject.slice(0, 3).toLowerCase(); |
| const prefix = `tch-${subCode}-${initials}-`; |
| const num = await getNextNumber(prefix); |
| const teacherId = `${prefix}${num}`; |
| |
| const pinHash = await bcrypt.hash(pin, 10); |
| |
| const userProfile = { |
| id: teacherId, |
| fullName, |
| role: 'teacher', |
| subject, |
| position, |
| classesManaged: classesManaged || [], |
| pinHash, |
| createdAt: new Date().toISOString() |
| }; |
| |
| const users = loadLocalUsers(); |
| users.push(userProfile); |
| saveLocalUsers(users); |
| |
| const customToken = jwt.sign({ uid: teacherId }, JWT_SECRET); |
| setJwtCookie(res, teacherId, 'teacher'); |
| |
| |
| const { pinHash: _, ...publicProfile } = userProfile; |
| res.json({ customToken, user: publicProfile }); |
| } catch (error: any) { |
| console.error("Register Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/auth/login", async (req, res) => { |
| try { |
| const { id, pin } = req.body; |
| if (!id || !pin) return res.status(400).json({ error: "Missing identity or pin" }); |
| |
| const userId = id.toLowerCase().trim(); |
| const users = loadLocalUsers(); |
| const user = users.find(u => u.id === userId); |
| |
| if (!user) { |
| return res.status(401).json({ error: "Invalid ID or PIN" }); |
| } |
| |
| const isValid = await bcrypt.compare(pin, user.pinHash); |
| |
| if (!isValid) { |
| return res.status(401).json({ error: "Invalid ID or PIN" }); |
| } |
| |
| const customToken = jwt.sign({ uid: userId }, JWT_SECRET); |
| const role = user.role || 'student'; |
| setJwtCookie(res, userId, role); |
| |
| |
| const { pinHash: _, ...publicProfile } = user; |
| res.json({ customToken, user: publicProfile }); |
| } catch (error: any) { |
| console.error("Login Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/auth/register/student-email", async (req, res) => { |
| try { |
| const { fullName, class: className, department, email, password } = req.body; |
| if (!email || !password) { |
| return res.status(400).json({ error: "Email and password are required" }); |
| } |
|
|
| const cleanEmail = email.trim().toLowerCase(); |
| |
| |
| const users = loadLocalUsers(); |
| const emailExists = users.some(u => u.email === cleanEmail); |
| if (emailExists) { |
| return res.status(400).json({ error: "An account with this email already exists." }); |
| } |
|
|
| const shortName = generateShortName(fullName); |
| const classPrefix = className.toLowerCase().replace(/\s+/g, '').replace(/[^-a-z0-9]/g, ''); |
| const prefix = `${classPrefix}-${shortName}-`; |
| const num = await getNextNumber(prefix); |
| const studentId = `${prefix}${num}`; |
| |
| const pinHash = await bcrypt.hash(password, 10); |
| |
| const userProfile = { |
| id: studentId, |
| fullName, |
| role: 'student', |
| class: className, |
| department: department || null, |
| email: cleanEmail, |
| pinHash, |
| createdAt: new Date().toISOString(), |
| examHistory: [] |
| }; |
| |
| users.push(userProfile); |
| saveLocalUsers(users); |
| |
| const customToken = jwt.sign({ uid: studentId }, JWT_SECRET); |
| setJwtCookie(res, studentId, 'student'); |
| |
| const { pinHash: _, ...publicProfile } = userProfile; |
| res.json({ customToken, user: publicProfile }); |
| } catch (error: any) { |
| console.error("Student Email Register Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/auth/register/teacher-email", async (req, res) => { |
| try { |
| const { fullName, subject, position, classesManaged, email, password } = req.body; |
| if (!email || !password) { |
| return res.status(400).json({ error: "Email and password are required" }); |
| } |
|
|
| const cleanEmail = email.trim().toLowerCase(); |
| |
| |
| const users = loadLocalUsers(); |
| const emailExists = users.some(u => u.email === cleanEmail); |
| if (emailExists) { |
| return res.status(400).json({ error: "An account with this email already exists." }); |
| } |
|
|
| const initials = fullName.trim().split(/\s+/).map(p => p[0]).join('').toLowerCase().slice(0, 2); |
| const subCode = SUBJECT_CODES[subject] || subject.slice(0, 3).toLowerCase(); |
| const prefix = `tch-${subCode}-${initials}-`; |
| const num = await getNextNumber(prefix); |
| const teacherId = `${prefix}${num}`; |
| |
| const pinHash = await bcrypt.hash(password, 10); |
| |
| const userProfile = { |
| id: teacherId, |
| fullName, |
| role: 'teacher', |
| subject, |
| position, |
| classesManaged: classesManaged || [], |
| email: cleanEmail, |
| pinHash, |
| createdAt: new Date().toISOString(), |
| examHistory: [] |
| }; |
| |
| users.push(userProfile); |
| saveLocalUsers(users); |
| |
| const customToken = jwt.sign({ uid: teacherId }, JWT_SECRET); |
| setJwtCookie(res, teacherId, 'teacher'); |
| |
| const { pinHash: _, ...publicProfile } = userProfile; |
| res.json({ customToken, user: publicProfile }); |
| } catch (error: any) { |
| console.error("Teacher Email Register Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/auth/login-email", async (req, res) => { |
| try { |
| const { email, password } = req.body; |
| if (!email || !password) return res.status(400).json({ error: "Missing email or password" }); |
| |
| const cleanEmail = email.trim().toLowerCase(); |
| const users = loadLocalUsers(); |
| const user = users.find(u => u.email === cleanEmail); |
| |
| if (!user) { |
| return res.status(401).json({ error: "Invalid Email or Password" }); |
| } |
| |
| const isValid = await bcrypt.compare(password, user.pinHash); |
| |
| if (!isValid) { |
| return res.status(401).json({ error: "Invalid Email or Password" }); |
| } |
| |
| const customToken = jwt.sign({ uid: user.id }, JWT_SECRET); |
| const role = user.role || 'student'; |
| setJwtCookie(res, user.id, role); |
| |
| const { pinHash: _, ...publicProfile } = user; |
| res.json({ customToken, user: publicProfile }); |
| } catch (error: any) { |
| console.error("Email Login Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
|
|
| app.get("/api/auth/me", async (req, res) => { |
| try { |
| |
| |
| const userId = await getAuthenticatedUserId(req); |
| |
| const users = loadLocalUsers(); |
| const user = users.find(u => u.id === userId); |
| if (!user) return res.status(404).json({ error: "User not found" }); |
| |
| const { pinHash: _, ...publicProfile } = user; |
| res.json(publicProfile); |
| } catch (error) { |
| res.status(401).json({ error: "Invalid session" }); |
| } |
| }); |
|
|
| app.post("/api/auth/logout", (req, res) => { |
| res.clearCookie('jwt_session'); |
| res.json({ success: true }); |
| }); |
|
|
| |
| app.post("/api/exams/verify-entry", async (req, res) => { |
| try { |
| const { examId, studentId, deviceInfo = "Unknown Device" } = req.body; |
| const ip = req.ip || 'unknown-ip'; |
|
|
| if (!examId || !studentId) { |
| return res.status(400).json({ error: "Exam ID and Student ID are required inputs." }); |
| } |
|
|
| const cleanExamId = examId.trim().toUpperCase(); |
| const cleanStudentId = studentId.trim().toLowerCase(); |
|
|
| |
| const exams = loadLocalExams(); |
| const exam = exams.find(e => e.id === cleanExamId); |
| if (!exam) { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Input Exam ID: ${cleanExamId} not found`); |
| return res.status(400).json({ error: "Invalid Exam ID" }); |
| } |
|
|
| |
| if (exam.status !== 'active' && exam.status !== 'published') { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Exam ID: ${cleanExamId} is in status: ${exam.status}`); |
| return res.status(400).json({ error: "Exam is currently not active" }); |
| } |
|
|
| |
| const nowTimestamp = Date.now(); |
| if (exam.startTime && nowTimestamp < exam.startTime) { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Exam ID: ${cleanExamId} has not reached start schedule`); |
| return res.status(400).json({ error: "Exam not started" }); |
| } |
| if (exam.endTime && nowTimestamp > exam.endTime) { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Exam ID: ${cleanExamId} is past end schedule`); |
| return res.status(400).json({ error: "Exam ended" }); |
| } |
|
|
| |
| const users = loadLocalUsers(); |
| const student = users.find(u => u.id === cleanStudentId && u.role === 'student'); |
| if (!student) { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Input Student ID: ${cleanStudentId} not found`); |
| return res.status(400).json({ error: "Invalid Student ID" }); |
| } |
|
|
| |
| const hasStudentRestrictions = exam.studentIds && exam.studentIds.length > 0; |
| const hasGroupRestrictions = exam.groupIds && exam.groupIds.length > 0; |
|
|
| if (hasStudentRestrictions || hasGroupRestrictions) { |
| let isRegistered = false; |
|
|
| if (hasStudentRestrictions && exam.studentIds.includes(cleanStudentId)) { |
| isRegistered = true; |
| } |
|
|
| if (!isRegistered && hasGroupRestrictions) { |
| const groups = loadLocalGroups(); |
| const studentGroupIds = groups |
| .filter(g => g.studentIds && g.studentIds.includes(cleanStudentId)) |
| .map(g => g.id); |
|
|
| const overlaps = exam.groupIds.some((gId: string) => studentGroupIds.includes(gId)); |
| if (overlaps) { |
| isRegistered = true; |
| } |
| } |
|
|
| if (!isRegistered) { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Student is not assigned to exam ${cleanExamId}`); |
| return res.status(400).json({ error: "Student not registered" }); |
| } |
| } |
|
|
| |
| const attempts = loadLocalAttempts(); |
| const completedAttempts = attempts.filter( |
| a => a.examId === cleanExamId && a.studentId === cleanStudentId && a.status === 'completed' |
| ); |
|
|
| if (exam.settings && exam.settings.attemptLimit > 0) { |
| if (completedAttempts.length >= exam.settings.attemptLimit) { |
| await logAudit("Failed Exam Access Attempt", cleanStudentId, `Attempt limit reached for exam ${cleanExamId}`); |
| return res.status(400).json({ error: "Attempt limit reached" }); |
| } |
| } |
|
|
| |
| const sessions = loadLocalSessions(); |
| let session = sessions.find( |
| s => s.examId === cleanExamId && s.studentId === cleanStudentId && s.status === 'active' |
| ); |
|
|
| const sessionUUID = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 10); |
| const newSessionToken = `SESSION-${sessionUUID}`; |
|
|
| if (session) { |
| |
| await logAudit("Exam Resumed", cleanStudentId, `Resumed active session. New token: ${newSessionToken}`); |
| session.oldToken = session.id; |
| session.id = newSessionToken; |
| session.ip = ip; |
| session.deviceInfo = deviceInfo; |
| session.lastActive = nowTimestamp; |
| } else { |
| |
| await logAudit("Exam Started", cleanStudentId, `Created new session token: ${newSessionToken}`); |
| session = { |
| id: newSessionToken, |
| studentId: cleanStudentId, |
| studentName: student.fullName, |
| studentClass: student.class || "General", |
| examId: cleanExamId, |
| examName: exam.title, |
| subject: exam.subject || "General", |
| duration: exam.duration, |
| questionsCount: exam.questionIds ? exam.questionIds.length : 0, |
| tempTimer: exam.duration * 60, |
| answers: {}, |
| currentQuestionId: exam.questionIds && exam.questionIds.length > 0 ? exam.questionIds[0] : "", |
| startedAt: nowTimestamp, |
| lastActive: nowTimestamp, |
| ip, |
| deviceInfo, |
| status: 'active' |
| }; |
| sessions.push(session); |
| } |
|
|
| saveLocalSessions(sessions); |
|
|
| |
| setJwtCookie(res, cleanStudentId, 'student'); |
| const customToken = jwt.sign({ uid: cleanStudentId }, JWT_SECRET); |
|
|
| res.json({ |
| sessionToken: newSessionToken, |
| customToken, |
| session, |
| studentName: student.fullName, |
| examName: exam.title, |
| subject: exam.subject || "General", |
| duration: exam.duration, |
| questionsCount: exam.questionIds ? exam.questionIds.length : 0, |
| }); |
|
|
| } catch (error: any) { |
| console.error("verify-entry endpoints error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/sessions/update-state", async (req, res) => { |
| try { |
| const { sessionToken, answers, currentQuestionId, remainingTime } = req.body; |
| if (!sessionToken) { |
| return res.status(400).json({ error: "Missing session token" }); |
| } |
|
|
| const sessions = loadLocalSessions(); |
| const session = sessions.find(s => s.id === sessionToken && s.status === 'active'); |
| if (!session) { |
| return res.status(404).json({ error: "Active session not found or has expired." }); |
| } |
|
|
| |
| if (answers !== undefined) session.answers = answers; |
| if (currentQuestionId !== undefined) session.currentQuestionId = currentQuestionId; |
| if (remainingTime !== undefined) session.tempTimer = remainingTime; |
| |
| session.lastActive = Date.now(); |
| saveLocalSessions(sessions); |
|
|
| res.json({ success: true, lastActive: session.lastActive }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.get("/api/sessions/status/:token", async (req, res) => { |
| try { |
| const { token } = req.params; |
| const sessions = loadLocalSessions(); |
| const session = sessions.find(s => s.id === token); |
| if (!session) { |
| return res.status(404).json({ error: "Session token index not recognized" }); |
| } |
| res.json(session); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/exams/create", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| |
| const users = await loadUsersFromDb(); |
| const teacher = users.find(u => u.id === teacherId); |
| if (!teacher || teacher.role !== 'teacher') { |
| return res.status(403).json({ error: "Only teachers can create exams" }); |
| } |
| |
| const examData = req.body; |
| const exams = await loadExamsFromDb(); |
| const existingIds = exams.map(e => e.id); |
| const examId = generateExamId(existingIds); |
| |
| const now = new Date().toISOString(); |
| const newExam = { |
| ...examData, |
| id: examId, |
| examId: examId, |
| examName: examData.title || examData.examName || "", |
| title: examData.title || examData.examName || "", |
| teacherId, |
| createdBy: teacherId, |
| schoolId: teacher.schoolId || teacher.class || "school-primary", |
| createdAt: now, |
| updatedAt: now, |
| status: 'draft' |
| }; |
| |
| await saveExamToDb(newExam); |
| res.json({ id: examId }); |
| } catch (error: any) { |
| console.error("Exam Create Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/questions/create", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| |
| const prefix = 'qst-'; |
| const num = await getNextNumber(prefix); |
| const questionId = `${prefix}${num}`; |
| |
| const questionData = { |
| ...req.body, |
| id: questionId, |
| teacherId, |
| createdAt: new Date().toISOString(), |
| }; |
| |
| await saveQuestionToDb(questionData); |
| res.json(questionData); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/groups/create", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| |
| const prefix = 'grp-'; |
| const num = await getNextNumber(prefix); |
| const groupId = `${prefix}${num}`; |
| |
| const groupData = { |
| ...req.body, |
| id: groupId, |
| teacherId, |
| createdAt: new Date().toISOString(), |
| }; |
| |
| const groups = loadLocalGroups(); |
| groups.push(groupData); |
| saveLocalGroups(groups); |
| res.json(groupData); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/exams/submit", async (req, res) => { |
| try { |
| const studentId = await getAuthenticatedUserId(req); |
| const { attemptId, examId, answers, timeSpent, offlineDuration = 0, submissionTimestamp } = req.body; |
| |
| |
| const exams = loadLocalExams(); |
| const exam = exams.find(e => e.id === examId); |
| if (!exam) return res.status(404).json({ error: "Exam not found" }); |
|
|
| |
| const now = Date.now(); |
| if (exam.startTime && now < exam.startTime) { |
| return res.status(400).json({ error: "This exam schedule has not started yet." }); |
| } |
| if (exam.endTime) { |
| const gracePeriod = 15 * 60 * 1000; |
| if (now > exam.endTime + gracePeriod) { |
| return res.status(400).json({ error: "The exam window has officially closed. Delayed submission rejected." }); |
| } |
| } |
|
|
| |
| if (submissionTimestamp) { |
| const clientSubmitTime = new Date(submissionTimestamp).getTime(); |
| const clockSkew = Math.abs(now - clientSubmitTime); |
| if (clockSkew > 10 * 60 * 1000) { |
| console.warn(`Clock tampering suspected for ${studentId}. Skew: ${clockSkew}ms`); |
| } |
| } |
| |
| const questionIds = exam.questionIds || []; |
| |
| const questionsList = loadLocalQuestions(); |
| const questionsMap: Record<string, any> = {}; |
| questionsList.forEach(q => { |
| if (questionIds.includes(q.id)) { |
| questionsMap[q.id] = q; |
| } |
| }); |
| |
| let score = 0; |
| const total = questionIds.length; |
| |
| questionIds.forEach((qId: string) => { |
| if (questionsMap[qId] && answers[qId] === questionsMap[qId].correctOptionId) { |
| score++; |
| } |
| }); |
| |
| const percentage = total > 0 ? Math.round((score / total) * 100) : 0; |
| const completedAt = new Date().toISOString(); |
| |
| const attempts = loadLocalAttempts(); |
| const attemptIdx = attempts.findIndex(a => a.id === attemptId); |
|
|
| |
| let validatedTimeSpent = timeSpent; |
| if (attemptIdx !== -1) { |
| const attemptObj = attempts[attemptIdx]; |
| if (attemptObj.startedAt) { |
| const startedTimeMs = new Date(attemptObj.startedAt).getTime(); |
| const serverElapsedSeconds = Math.floor((now - startedTimeMs) / 1000); |
| const maxExpectedSeconds = (exam.duration * 60) + offlineDuration + 600; |
| |
| |
| |
| if (serverElapsedSeconds > maxExpectedSeconds) { |
| console.warn(`Timer manipulation warning for student ${studentId}. Elapsed: ${serverElapsedSeconds}s, Reported Spent: ${timeSpent}s, Limit: ${maxExpectedSeconds}s`); |
| validatedTimeSpent = Math.min(serverElapsedSeconds, exam.duration * 60); |
| } |
| } |
| } |
|
|
| if (attemptIdx !== -1) { |
| attempts[attemptIdx] = { |
| ...attempts[attemptIdx], |
| answers, |
| score, |
| percentage, |
| timeSpent: validatedTimeSpent, |
| offlineDuration, |
| status: 'completed', |
| completedAt |
| }; |
| saveLocalAttempts(attempts); |
| } else { |
| attempts.push({ |
| id: attemptId, |
| examId, |
| studentId, |
| studentName: 'Student', |
| studentClass: 'Class', |
| status: 'completed', |
| answers, |
| score, |
| percentage, |
| timeSpent: validatedTimeSpent, |
| offlineDuration, |
| startedAt: completedAt, |
| completedAt |
| }); |
| saveLocalAttempts(attempts); |
| } |
| |
| |
| const users = loadLocalUsers(); |
| const student = users.find(u => u.id === studentId); |
| if (student) { |
| if (!student.examHistory) student.examHistory = []; |
| if (!student.examHistory.includes(attemptId)) { |
| student.examHistory.push(attemptId); |
| } |
| saveLocalUsers(users); |
| } |
| |
| res.json({ score, percentage, total }); |
| } catch (error: any) { |
| console.error("Submission Error:", error); |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
|
|
| app.get("/api/students", async (req, res) => { |
| try { |
| const users = loadLocalUsers(); |
| const students = users.filter(u => u.role === 'student'); |
| res.json(students); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/questions/list", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const questions = loadLocalQuestions(); |
| const filtered = questions.filter(q => q.teacherId === teacherId); |
| res.json(filtered); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.delete("/api/questions/delete/:id", async (req, res) => { |
| try { |
| await getAuthenticatedUserId(req); |
| const { id } = req.params; |
| let questions = loadLocalQuestions(); |
| questions = questions.filter(q => q.id !== id); |
| saveLocalQuestions(questions); |
| res.json({ success: true }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.delete("/api/groups/delete/:id", async (req, res) => { |
| try { |
| await getAuthenticatedUserId(req); |
| const { id } = req.params; |
| let groups = loadLocalGroups(); |
| groups = groups.filter(g => g.id !== id); |
| saveLocalGroups(groups); |
| res.json({ success: true }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/groups/list", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const groups = loadLocalGroups(); |
| const filtered = groups.filter(g => g.teacherId === teacherId); |
| res.json(filtered); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/student/groups", async (req, res) => { |
| try { |
| const studentId = await getAuthenticatedUserId(req); |
| const groups = loadLocalGroups(); |
| const filtered = groups.filter(g => g.studentIds && g.studentIds.includes(studentId)); |
| res.json(filtered); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/attempts/get/:id", async (req, res) => { |
| try { |
| const { id } = req.params; |
| const attempts = loadLocalAttempts(); |
| const attempt = attempts.find(a => a.id === id); |
| if (!attempt) return res.status(404).json({ error: "Attempt not found" }); |
| res.json(attempt); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/questions/batch", async (req, res) => { |
| try { |
| const { ids } = req.body; |
| const questions = loadLocalQuestions(); |
| const filtered = questions.filter(q => ids.includes(q.id)); |
| res.json(filtered); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/exams/get/:id", async (req, res) => { |
| try { |
| const { id } = req.params; |
| const exams = await loadExamsFromDb(); |
| const exam = exams.find(e => e.id === id); |
| if (!exam) return res.status(404).json({ error: "Exam not found" }); |
| res.json(exam); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/exams/list", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const exams = await loadExamsFromDb(); |
| const filtered = exams.filter(e => e.teacherId === teacherId); |
| filtered.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); |
| |
| |
| const attempts = await loadAttemptsFromDb(); |
| const enriched = filtered.map((exam: any) => { |
| const examAttempts = attempts.filter((a: any) => a.examId === exam.id && a.status === 'completed'); |
| const totalScore = examAttempts.reduce((acc: number, curr: any) => acc + (curr.percentage || 0), 0); |
| const avg = examAttempts.length > 0 ? Math.round(totalScore / examAttempts.length) : 0; |
| return { |
| ...exam, |
| avgPerformance: avg, |
| attemptsCount: examAttempts.length |
| }; |
| }); |
| |
| res.json(enriched); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/exams/update/:id", async (req, res) => { |
| try { |
| await getAuthenticatedUserId(req); |
| const { id } = req.params; |
| const updateData = req.body; |
| const exams = await loadExamsFromDb(); |
| const idx = exams.findIndex(e => e.id === id); |
| if (idx !== -1) { |
| const updatedExam = { |
| ...exams[idx], |
| ...updateData, |
| updatedAt: new Date().toISOString() |
| }; |
| |
| updatedExam.status = getTransitionedStatus(updatedExam); |
| |
| await saveExamToDb(updatedExam); |
| res.json({ success: true }); |
| } else { |
| res.status(404).json({ error: "Exam not found" }); |
| } |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/exams/attempts/:examId", async (req, res) => { |
| try { |
| const { examId } = req.params; |
| const attempts = loadLocalAttempts(); |
| const filtered = attempts.filter(a => a.examId === examId); |
| res.json(filtered); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/attempts/start", async (req, res) => { |
| try { |
| const { examId, student } = req.body; |
| const attempts = loadLocalAttempts(); |
| |
| const existing = attempts.find(a => a.examId === examId && a.studentId === student.id && a.status === 'started'); |
| if (existing) { |
| return res.json({ attemptId: existing.id }); |
| } |
| |
| const attemptId = `ATT-${Math.random().toString(36).substring(2, 6).toUpperCase()}`; |
| const attempt = { |
| id: attemptId, |
| examId, |
| studentId: student.id, |
| studentName: student.fullName, |
| studentClass: student.class || 'Unknown', |
| status: 'started', |
| answers: {}, |
| score: 0, |
| percentage: 0, |
| timeSpent: 0, |
| startedAt: new Date().toISOString() |
| }; |
| |
| attempts.push(attempt); |
| saveLocalAttempts(attempts); |
| res.json({ attemptId }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/attempts/progress/:id", async (req, res) => { |
| try { |
| const { id } = req.params; |
| const { answers, timeSpent } = req.body; |
| const attempts = loadLocalAttempts(); |
| const idx = attempts.findIndex(a => a.id === id); |
| if (idx !== -1) { |
| attempts[idx].answers = answers; |
| attempts[idx].timeSpent = timeSpent; |
| attempts[idx].updatedAt = new Date().toISOString(); |
| saveLocalAttempts(attempts); |
| res.json({ success: true }); |
| } else { |
| res.status(404).json({ error: "Attempt not found" }); |
| } |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/student/attempts", async (req, res) => { |
| try { |
| const studentId = await getAuthenticatedUserId(req); |
| const attempts = loadLocalAttempts(); |
| const filtered = attempts.filter(a => a.studentId === studentId); |
| res.json(filtered); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/drafts/sync", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const draftData = req.body; |
| draftData.updatedAt = new Date().toISOString(); |
| |
| |
| try { |
| await db.collection("drafts").doc(teacherId).set(draftData); |
| } catch (e) { |
| console.warn("Firestore draft sync failed:", e); |
| } |
| |
| |
| const file = path.join(process.cwd(), `draft-${teacherId}.json`); |
| fs.writeFileSync(file, JSON.stringify(draftData, null, 2), 'utf-8'); |
| |
| res.json({ success: true, draft: draftData }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/drafts/get", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| |
| |
| let localDraft: any = null; |
| const file = path.join(process.cwd(), `draft-${teacherId}.json`); |
| if (fs.existsSync(file)) { |
| try { |
| localDraft = JSON.parse(fs.readFileSync(file, 'utf-8')); |
| } catch {} |
| } |
| |
| |
| let remoteDraft: any = null; |
| try { |
| const doc = await db.collection("drafts").doc(teacherId).get(); |
| if (doc.exists) { |
| remoteDraft = doc.data(); |
| } |
| } catch (e) { |
| console.warn("Firestore draft retrieving failed:", e); |
| } |
| |
| |
| if (localDraft && remoteDraft) { |
| const localTime = new Date(localDraft.updatedAt || 0).getTime(); |
| const remoteTime = new Date(remoteDraft.updatedAt || 0).getTime(); |
| if (remoteTime > localTime) { |
| res.json(remoteDraft); |
| } else { |
| res.json(localDraft); |
| } |
| } else { |
| res.json(remoteDraft || localDraft || null); |
| } |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/drafts/clear", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const file = path.join(process.cwd(), `draft-${teacherId}.json`); |
| if (fs.existsSync(file)) { |
| fs.unlinkSync(file); |
| } |
| try { |
| await db.collection("drafts").doc(teacherId).delete(); |
| } catch {} |
| res.json({ success: true }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/teacher/stats", async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const exams = (await loadExamsFromDb()).filter(e => e.teacherId === teacherId); |
| const questions = (await loadQuestionsFromDb()).filter(q => q.teacherId === teacherId); |
| const examIds = exams.map(e => e.id); |
| const attempts = (await loadAttemptsFromDb()).filter(a => examIds.includes(a.examId)); |
| |
| let totalScore = 0; |
| attempts.forEach(a => { |
| totalScore += a.percentage || 0; |
| }); |
| |
| const users = await loadUsersFromDb(); |
| const students = users.filter(u => u.role === 'student'); |
| |
| res.json({ |
| totalExams: exams.length, |
| totalQuestions: questions.length, |
| totalSubmissions: attempts.length, |
| totalStudents: students.length, |
| avgScore: attempts.length > 0 ? Math.round(totalScore / attempts.length) : 0 |
| }); |
| } catch (error: any) { |
| res.status(500).json({ error: error.message }); |
| } |
| }); |
|
|
| |
| const aiCache = new Map<string, { result: any; expiresAt: number }>(); |
| const clientAiUsage: Record<string, { requests: number; windowStart: number }> = {}; |
|
|
| |
| function aiRateLimiter(req: any, res: any, next: any) { |
| const ip = req.ip || 'unknown-ip'; |
| const userId = req.cookies?.jwt_session ? (jwt.decode(req.cookies.jwt_session) as any)?.uid : ip; |
| const key = `${userId || 'anon'}`; |
| const now = Date.now(); |
| const windowMs = 60 * 1000; |
| const maxRequestsPerMinute = 15; |
|
|
| if (!clientAiUsage[key]) { |
| clientAiUsage[key] = { requests: 1, windowStart: now }; |
| } else { |
| const limit = clientAiUsage[key]; |
| if (now - limit.windowStart > windowMs) { |
| limit.requests = 1; |
| limit.windowStart = now; |
| } else { |
| limit.requests++; |
| if (limit.requests > maxRequestsPerMinute) { |
| return res.status(429).json({ |
| error: "AI processing limit reached. Please try again shortly." |
| }); |
| } |
| } |
| } |
| next(); |
| } |
|
|
| |
| function routeModel(taskType: string, inputLength: number): { model: string; reason: string } { |
| |
| |
| const isHeavyTask = [ |
| 'integrity_validation', |
| 'difficulty_analysis', |
| 'duplicate_detection', |
| 'analytics_interpretation' |
| ].includes(taskType); |
|
|
| if (isHeavyTask || inputLength > 8000) { |
| return { |
| model: 'meta/llama-3.1-70b-instruct', |
| reason: isHeavyTask |
| ? `Escalated to Llama 3.1 70B Instruct due to cognitive reasoning category: [${taskType}]` |
| : 'Escalated to Llama 3.1 70B Instruct due to heavy input sequence size (>8KB).' |
| }; |
| } |
|
|
| return { |
| model: 'meta/llama-3.1-8b-instruct', |
| reason: `Routed to Llama 3.1 8B Instruct for cost-aware rapid throughput: [${taskType}]` |
| }; |
| } |
|
|
| |
| async function callNvidiaNim(model: string, systemPrompt: string, userPrompt: string, maxTokens = 4096): Promise<string> { |
| const apiKey = process.env.NVIDIA_API_KEY; |
| if (!apiKey) { |
| throw new Error("NVIDIA_API_KEY is not configured on the server. Please enter your NVIDIA API token in the settings panel."); |
| } |
|
|
| const nvResponse = await fetch("https://integrate.api.nvidia.com/v1/chat/completions", { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Authorization': `Bearer ${apiKey}` |
| }, |
| body: JSON.stringify({ |
| model: model, |
| messages: [ |
| { role: 'system', content: systemPrompt }, |
| { role: 'user', content: userPrompt } |
| ], |
| temperature: 0.1, |
| max_tokens: maxTokens |
| }) |
| }); |
|
|
| if (!nvResponse.ok) { |
| const errText = await nvResponse.text(); |
| throw new Error(`NVIDIA NIM API call failed (${nvResponse.status}): ${errText}`); |
| } |
|
|
| const data: any = await nvResponse.json(); |
| return data.choices[0]?.message?.content || ""; |
| } |
|
|
| |
| interface AsyncJob { |
| id: string; |
| status: 'queued' | 'parsing' | 'validating' | 'completed' | 'failed'; |
| progressLabel: string; |
| progressPercent: number; |
| result?: any; |
| error?: string; |
| createdAt: number; |
| } |
| const activeJobs: Record<string, AsyncJob> = {}; |
|
|
| |
| function sanitizePromptInput(text: string): string { |
| if (!text) return ""; |
| |
| return text |
| .replace(/ignore previous instructions/gi, "[blocked injection]") |
| .replace(/you are now an agent/gi, "[blocked injection]") |
| .replace(/system: /gi, "user-content: ") |
| .trim(); |
| } |
|
|
| |
| function robustJsonParse(raw: string): any { |
| let cleaned = raw.trim(); |
| |
| |
| if (cleaned.startsWith("```json")) { |
| cleaned = cleaned.replace(/^```json/, "").replace(/```$/, "").trim(); |
| } else if (cleaned.startsWith("```")) { |
| cleaned = cleaned.replace(/^```/, "").replace(/```$/, "").trim(); |
| } |
| |
| |
| cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, ""); |
| cleaned = cleaned.replace(/(?:^|\s)\/\/.*$/gm, ""); |
|
|
| const performBasicRegexCleanup = (s: string) => { |
| |
| return s.replace(/,(\s*[\]\}])/g, "$1"); |
| }; |
|
|
| try { |
| return JSON.parse(performBasicRegexCleanup(cleaned)); |
| } catch (e) { |
| |
| const arrayMatch = cleaned.match(/\[\s*\{[\s\S]*\}\s*\]/); |
| if (arrayMatch) { |
| try { |
| return JSON.parse(performBasicRegexCleanup(arrayMatch[0])); |
| } catch (err) {} |
| } |
| |
| const objMatch = cleaned.match(/\{\s*[\s\S]*\}/); |
| if (objMatch) { |
| try { |
| return JSON.parse(performBasicRegexCleanup(objMatch[0])); |
| } catch (err) {} |
| } |
|
|
| |
| try { |
| let insideQuote = false; |
| let escaped = false; |
| let buffer = ''; |
| for (let i = 0; i < cleaned.length; i++) { |
| const char = cleaned[i]; |
| if (escaped) { |
| buffer += char; |
| escaped = false; |
| continue; |
| } |
| if (char === '\\') { |
| buffer += char; |
| escaped = true; |
| continue; |
| } |
| if (char === '"') { |
| insideQuote = !insideQuote; |
| buffer += char; |
| continue; |
| } |
| if (insideQuote && (char === '\n' || char === '\r')) { |
| buffer += '\\n'; |
| } else if (insideQuote && char === '\t') { |
| buffer += '\\t'; |
| } else { |
| buffer += char; |
| } |
| } |
| return JSON.parse(performBasicRegexCleanup(buffer)); |
| } catch (eInner) { |
| throw e; |
| } |
| } |
| } |
|
|
| |
| async function logAiOperation(userId: string, taskType: string, model: string, routingReason: string, inputLength: number, durationMs: number, status: 'success' | 'failed', errorMsg?: string) { |
| try { |
| let logs: any[] = []; |
| if (fs.existsSync(AI_LOGS_FILE)) { |
| logs = JSON.parse(fs.readFileSync(AI_LOGS_FILE, 'utf-8')); |
| } |
| logs.push({ |
| userId, |
| taskType, |
| model, |
| routingReason, |
| inputLength, |
| durationMs, |
| status, |
| errorMsg: errorMsg || null, |
| timestamp: new Date().toISOString() |
| }); |
| fs.writeFileSync(AI_LOGS_FILE, JSON.stringify(logs, null, 2), 'utf-8'); |
| } catch (err) { |
| console.warn("Failed to log AI trace locally:", err); |
| } |
| } |
|
|
| |
| app.post("/api/ai/jobs/start", aiRateLimiter, async (req, res) => { |
| try { |
| const teacherId = await getAuthenticatedUserId(req); |
| const { text, title = "Exam Sheet" } = req.body; |
| if (!text || text.trim().length === 0) { |
| return res.status(400).json({ error: "Missing document text parameters to compile" }); |
| } |
|
|
| const sanitizedText = sanitizePromptInput(text); |
| const textHash = crypto.createHash ? crypto.createHash('sha256').update(sanitizedText).digest('hex') : sanitizedText.length.toString(); |
|
|
| |
| const cachedResult = aiCache.get(textHash); |
| if (cachedResult && Date.now() < cachedResult.expiresAt) { |
| const cachedJobId = `job-cached-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`; |
| activeJobs[cachedJobId] = { |
| id: cachedJobId, |
| status: 'completed', |
| progressLabel: "Exam retrieved from local high-speed cache!", |
| progressPercent: 100, |
| result: cachedResult.result, |
| createdAt: Date.now() |
| }; |
| return res.json({ jobId: cachedJobId }); |
| } |
|
|
| |
| const jobId = `job-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; |
| activeJobs[jobId] = { |
| id: jobId, |
| status: 'queued', |
| progressLabel: "Exam compilation task queued successfully...", |
| progressPercent: 10, |
| createdAt: Date.now() |
| }; |
|
|
| |
| (async () => { |
| const startTime = Date.now(); |
| try { |
| |
| activeJobs[jobId].status = 'parsing'; |
| activeJobs[jobId].progressLabel = "Parsing document structure using fast Llama 3.1 8B..."; |
| activeJobs[jobId].progressPercent = 40; |
|
|
| const routerDecisionParser = routeModel('extraction', sanitizedText.length); |
| const systemPromptParser = `You are a strict, secure educational text-to-JSON parser. |
| Your task is to extract multiple choice questions (MCQ) from the raw user text input. |
| Output MUST be return as a valid JSON array matching the schema: |
| [ |
| { |
| "text": "The full question text?", |
| "options": [ |
| { "id": "opt-1", "text": "Option text" }, |
| { "id": "opt-2", "text": "Option text" }, |
| { "id": "opt-3", "text": "Option text" }, |
| { "id": "opt-4", "text": "Option text" } |
| ], |
| "correctOptionId": "opt-1", |
| "difficulty": "easy" | "medium" | "hard", |
| "category": "Algebra" |
| } |
| ] |
| Format Rules: |
| 1. Every question MUST have exactly 4 options with exact IDs: opt-1, opt-2, opt-3, opt-4. |
| 2. If correct answer is missing or not declared, deduce it realistically with your knowledge. |
| 3. Keep the JSON clean and valid. Do NOT append any markdown wrappers or comments. Only respond with raw parseable JSON string.`; |
|
|
| const parserOutput = await callNvidiaNim(routerDecisionParser.model, systemPromptParser, sanitizedText, 4096); |
| let cleanedParser = parserOutput.trim(); |
|
|
| let questionsList: any[] = []; |
| try { |
| questionsList = robustJsonParse(cleanedParser); |
| if (!Array.isArray(questionsList)) { |
| questionsList = [questionsList]; |
| } |
| } catch (jsonErr: any) { |
| console.warn("Llama 3.1 8B JSON parse recovery triggered:", jsonErr.message); |
| |
| const jsonMatch = cleanedParser.match(/\[\s*\{[\s\S]*\}\s*\]/); |
| if (jsonMatch) { |
| try { |
| questionsList = robustJsonParse(jsonMatch[0]); |
| } catch (fallbackErr: any) { |
| console.error("Llama 3.1 8B fallback parsing also failed:", fallbackErr.message); |
| throw new Error("Target response formatting was not compliant with schema standards. Please adjust inputs."); |
| } |
| } else { |
| throw new Error("Target response formatting was not compliant with schema standards. Please adjust inputs."); |
| } |
| } |
|
|
| |
| activeJobs[jobId].status = 'validating'; |
| activeJobs[jobId].progressLabel = "Auditing questions and academic integrity using Llama 3.1 70B..."; |
| activeJobs[jobId].progressPercent = 75; |
|
|
| const routerDecisionAudit = routeModel('integrity_validation', JSON.stringify(questionsList).length); |
| const systemPromptAudit = `You are an elite academic curriculum developer operating Llama 3.1 70B. |
| Evaluate the structured questions for deep instructional integrity: |
| 1. Examine structural errors (missing options, duplicates options list). |
| 2. Detect ambiguous options or questions that might contain multiple correct answers. |
| 3. Detect identical duplicate questions or near-duplicate phrasings. |
| 4. Calculate a quality grade score (0-100) and general difficulty index. |
| |
| Output must be in exact JSON layout: |
| { |
| "examQualityScore": 0-100, |
| "warnings": [ |
| { "type": "critical" | "warning" | "info", "message": "Clear specific warning of question index" } |
| ], |
| "duplicates": [ |
| { "indices": [0, 1], "reason": "Explanation" } |
| ] |
| } |
| Only output the JSON object without code blocks or markdown wrappers.`; |
|
|
| const auditOutput = await callNvidiaNim(routerDecisionAudit.model, systemPromptAudit, JSON.stringify(questionsList), 2048); |
| let cleanedAudit = auditOutput.trim(); |
|
|
| let auditReport: any = { examQualityScore: 90, warnings: [], duplicates: [] }; |
| try { |
| auditReport = robustJsonParse(cleanedAudit); |
| } catch (auditErr: any) { |
| console.warn("Llama 3.1 70B audit parsing fallback:", auditErr.message); |
| } |
|
|
| |
| const compileSummary = { |
| questions: questionsList, |
| auditReport, |
| timeSpentMs: Date.now() - startTime |
| }; |
|
|
| |
| aiCache.set(textHash, { result: compileSummary, expiresAt: Date.now() + 60 * 60 * 1000 }); |
|
|
| |
| activeJobs[jobId].status = 'completed'; |
| activeJobs[jobId].progressLabel = "Exam structure audited & successfully compiled!"; |
| activeJobs[jobId].progressPercent = 100; |
| activeJobs[jobId].result = compileSummary; |
|
|
| |
| await logAiOperation( |
| teacherId, |
| 'document_compilation_pipeline', |
| `${routerDecisionParser.model} + ${routerDecisionAudit.model}`, |
| `${routerDecisionParser.reason} | ${routerDecisionAudit.reason}`, |
| text.length, |
| Date.now() - startTime, |
| 'success' |
| ); |
|
|
| } catch (bgError: any) { |
| console.error("Background AI Orchestrator crashed:", bgError); |
| activeJobs[jobId].status = 'failed'; |
| activeJobs[jobId].progressLabel = "Compilation pipeline failed."; |
| activeJobs[jobId].error = bgError.message || "An unexpected failure occurred inside NVIDIA NIM API pipelines."; |
| |
| await logAiOperation( |
| teacherId, |
| 'document_compilation_pipeline', |
| 'hybrid', |
| 'Failed inside async promise pipeline handler', |
| text.length, |
| Date.now() - startTime, |
| 'failed', |
| bgError.message |
| ); |
| } |
| })(); |
|
|
| res.json({ jobId }); |
|
|
| } catch (err: any) { |
| console.error("Jobs Start Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.get("/api/ai/jobs/status/:id", async (req, res) => { |
| try { |
| const jobId = req.params.id; |
| const job = activeJobs[jobId]; |
| if (!job) { |
| return res.status(404).json({ error: "Compilation job task not found." }); |
| } |
| res.json(job); |
| } catch (err: any) { |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/validate", aiRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req); |
| const { questions } = req.body; |
| if (!questions || !Array.isArray(questions)) { |
| return res.status(400).json({ error: "Missing structured questions list parameters" }); |
| } |
|
|
| const inputDataStr = JSON.stringify(questions); |
| const hashKey = crypto.createHash ? crypto.createHash('sha256').update("validate:" + inputDataStr).digest('hex') : "validate:" + inputDataStr.length; |
|
|
| |
| const cached = aiCache.get(hashKey); |
| if (cached) { |
| return res.json(cached); |
| } |
|
|
| const systemPrompt = `You are an elite educational quality inspector operating Llama 3.1 70B. |
| Scan the questions for any errors: malformed MCQs, duplicate options, missing options, multiple correct options, ambiguous questions, near-duplicates, clarity score (0-100). |
| Output must follow the JSON schema ONLY: |
| { |
| "examQualityScore": 0-100, |
| "warnings": [ |
| { "type": "critical"|"warning"|"info", "message": "Short actionable academic feedback" } |
| ], |
| "duplicates": [ |
| { "indices": [0,1], "reason": "Explanation" } |
| ] |
| } |
| No conversational padding! Format as pure parseable JSON.`; |
|
|
| const rawResponse = await callNvidiaNim('meta/llama-3.1-70b-instruct', systemPrompt, inputDataStr, 2048); |
| let cleaned = rawResponse.trim(); |
|
|
| const auditReport = robustJsonParse(cleaned); |
| aiCache.set(hashKey, { result: auditReport, expiresAt: Date.now() + 20 * 60 * 1000 }); |
|
|
| await logAiOperation(userId, 'integrity_validation', 'meta/llama-3.1-70b-instruct', 'Explicit request validation', inputDataStr.length, Date.now() - startTime, 'success'); |
|
|
| res.json(auditReport); |
| } catch (err: any) { |
| console.error("AI Validate Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/analytics-explain", aiRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req); |
| const { metrics } = req.body; |
| if (!metrics) { |
| return res.status(400).json({ error: "Missing quantitative assessment parameters to explain" }); |
| } |
|
|
| const contentStr = JSON.stringify(metrics); |
| const hashKey = "analytics:" + contentStr; |
| const cached = aiCache.get(hashKey); |
| if (cached) return res.json(cached); |
|
|
| const systemPrompt = `You are an expert educational psychologist & strategic consultant. |
| Translate raw CBT student metrics (scores, failed items, category details) into highly scannable, actionable educational insights. |
| Do not wrap responses in conversational noise or markdown. Provide response inside a clean standardized schema layout: |
| { |
| "summary": "High level expert review summary", |
| "strengths": ["Item strength 1", "Item strength 2"], |
| "weaknesses": ["Improvement point 1", "Improvement point 2"], |
| "recommendations": ["Instructional adjustment advice 1", "Instructional adjustment advice 2"] |
| }`; |
|
|
| const rawResult = await callNvidiaNim('meta/llama-3.1-70b-instruct', systemPrompt, contentStr, 2048); |
| let cleaned = rawResult.trim(); |
|
|
| const explanation = robustJsonParse(cleaned); |
| aiCache.set(hashKey, { result: explanation, expiresAt: Date.now() + 60 * 60 * 1000 }); |
|
|
| await logAiOperation(userId, 'analytics_interpretation', 'meta/llama-3.1-70b-instruct', 'High-level analytics explanation', contentStr.length, Date.now() - startTime, 'success'); |
|
|
| res.json(explanation); |
| } catch (err: any) { |
| console.error("Analytics Interpretation Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/assistant", aiRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req); |
| const { prompt, context } = req.body; |
| if (!prompt) { |
| return res.status(400).json({ error: "Missing conversational instructions" }); |
| } |
|
|
| const cleanPrompt = sanitizePromptInput(prompt); |
| const complexityString = cleanPrompt + JSON.stringify(context || {}); |
| |
| |
| const routerDecision = routeModel('teacher_assistant', complexityString.length); |
|
|
| const systemPrompt = `You are ExamForge's integrated educational assistant. |
| Provide academic, concise, highly professional feedback on assessment structure, topics, and question formulations. |
| Never speak in overly casual greetings, do not use excessive emojis, and do NOT larp. Keep responses humble, intelligent and direct. |
| Current operational context: ${JSON.stringify(context || {})}`; |
|
|
| const output = await callNvidiaNim(routerDecision.model, systemPrompt, cleanPrompt, 2048); |
|
|
| await logAiOperation( |
| userId, |
| 'teacher_assistant', |
| routerDecision.model, |
| routerDecision.reason, |
| complexityString.length, |
| Date.now() - startTime, |
| 'success' |
| ); |
|
|
| res.json({ response: output.trim(), modelUsed: routerDecision.model }); |
| } catch (err: any) { |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
|
|
| |
|
|
| |
| app.post("/api/ai/generate-questions", nvidiaRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req).catch(() => "anonymous-student"); |
| const { prompt, count = 5 } = req.body; |
| if (!prompt) return res.status(400).json({ error: "Instruction prompt is required" }); |
|
|
| const systemPrompt = `You are an elite academic curriculum architect using Llama 3.1 70B. |
| Generate exactly ${count} multiple-choice questions (MCQ) regarding the subject/text: "${prompt}". |
| Your response MUST be valid JSON array of objects strictly following this format (no conversational chatter, no backticks, no markdown): |
| [ |
| { |
| "id": "qst-random8", |
| "text": "Correct academic question formulation?", |
| "options": [ |
| { "id": "opt-1", "text": "Option A" }, |
| { "id": "opt-2", "text": "Option B" }, |
| { "id": "opt-3", "text": "Option C" }, |
| { "id": "opt-4", "text": "Option D" } |
| ], |
| "correctOptionId": "opt-1", |
| "difficulty": "easy" | "medium" | "hard", |
| "category": "${prompt}", |
| "tags": [] |
| } |
| ]`; |
|
|
| const rawResponse = await callNvidiaNim('meta/llama-3.1-70b-instruct', systemPrompt, prompt, 3000); |
| const parsed = robustJsonParse(rawResponse.trim()); |
| |
| await logAiOperation(userId, 'generate_questions', 'meta/llama-3.1-70b-instruct', 'NvidiaAIService API call', prompt.length, Date.now() - startTime, 'success'); |
| res.json({ questions: parsed }); |
| } catch (err: any) { |
| console.error("AI Generate Questions Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/evaluate-answers", nvidiaRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req).catch(() => "anonymous-student"); |
| const { questions, answers } = req.body; |
| if (!questions || !answers) return res.status(400).json({ error: "Questions list and answers list are required" }); |
|
|
| const systemPrompt = `You are an elite academic scorer running on NVIDIA Nemotron-4-340B. |
| Evaluate the correctness of the student's selected answers against the core questions context. |
| Provide an objective grading, percentage, and constructive diagnostic summary feedback. |
| Your output MUST be a clean parseable JSON object matching: |
| { |
| "score": number, |
| "total": number, |
| "percentage": number, |
| "feedback": "Constructive instructional diagnostics" |
| }`; |
|
|
| const rawResponse = await callNvidiaNim('nvidia/nemotron-4-340b-instruct', systemPrompt, JSON.stringify({ questions, answers }), 2000); |
| const parsed = robustJsonParse(rawResponse.trim()); |
|
|
| await logAiOperation(userId, 'evaluate_answers', 'nvidia/nemotron-4-340b-instruct', 'NvidiaAIService API call', JSON.stringify({ questions, answers }).length, Date.now() - startTime, 'success'); |
| res.json(parsed); |
| } catch (err: any) { |
| console.error("AI Evaluate Answers Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/explain-answer", nvidiaRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req).catch(() => "anonymous-student"); |
| const { question, selectedOptionId } = req.body; |
| if (!question) return res.status(400).json({ error: "Question context is required" }); |
|
|
| const systemPrompt = `You are a helpful, clear academic mentor operating Meta Llama 3.1 70B. |
| Analyze this MCQ question and provide a highly scannable, textbook-accurate explanation of: |
| - Why the declared correct option is correct. |
| - Why the student's selected option (ID: '${selectedOptionId}') is correct or incorrect. |
| Keep the explanation clear, professional, direct and humble. No conversational fluff or markdown code fence blocks.`; |
|
|
| const rawResponse = await callNvidiaNim('meta/llama-3.1-70b-instruct', systemPrompt, JSON.stringify({ question, selectedOptionId }), 2000); |
| |
| await logAiOperation(userId, 'explain_answer', 'meta/llama-3.1-70b-instruct', 'NvidiaAIService API call', JSON.stringify({ question, selectedOptionId }).length, Date.now() - startTime, 'success'); |
| res.json({ explanation: rawResponse.trim() }); |
| } catch (err: any) { |
| console.error("AI Explain Answer Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/generate-distractors", nvidiaRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req).catch(() => "anonymous-student"); |
| const { questionText, correctOptionText } = req.body; |
| if (!questionText || !correctOptionText) return res.status(400).json({ error: "Question and correct option are required" }); |
|
|
| const systemPrompt = `You are a micro-instructional parser operating fast Llama 3.1 8B. |
| Generate exactly 3 believable, common-misconception multiple choice distractors (wrong answers) for: |
| Question: "${questionText}" |
| Correct Option: "${correctOptionText}" |
| Your response MUST be strict JSON object only: |
| { |
| "distractors": ["Wrong Option A", "Wrong Option B", "Wrong Option C"] |
| }`; |
|
|
| const rawResponse = await callNvidiaNim('meta/llama-3.1-8b-instruct', systemPrompt, `${questionText} | ${correctOptionText}`, 1000); |
| const parsed = robustJsonParse(rawResponse.trim()); |
|
|
| await logAiOperation(userId, 'generate_distractors', 'meta/llama-3.1-8b-instruct', 'NvidiaAIService API call', questionText.length, Date.now() - startTime, 'success'); |
| res.json(parsed); |
| } catch (err: any) { |
| console.error("AI Distractors Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/difficulty-analysis", nvidiaRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req).catch(() => "anonymous-student"); |
| const { questions } = req.body; |
| if (!questions) return res.status(400).json({ error: "Questions list is required" }); |
|
|
| const systemPrompt = `You are an elite educational psychometrics officer operating Llama 3.1 70B. |
| Analyze the cognitive load, logical rigor, and semantic difficulty of this question bank. |
| Your response MUST be valid JSON only: |
| { |
| "difficultyIndex": "easy" | "medium" | "hard", |
| "reasoningLoad": "low" | "medium" | "high", |
| "skillsRequired": ["Skill A", "Skill B"] |
| }`; |
|
|
| const rawResponse = await callNvidiaNim('meta/llama-3.1-70b-instruct', systemPrompt, JSON.stringify(questions), 2000); |
| const parsed = robustJsonParse(rawResponse.trim()); |
|
|
| await logAiOperation(userId, 'difficulty_analysis', 'meta/llama-3.1-70b-instruct', 'NvidiaAIService API call', JSON.stringify(questions).length, Date.now() - startTime, 'success'); |
| res.json(parsed); |
| } catch (err: any) { |
| console.error("AI Difficulty Analysis Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
| |
| app.post("/api/ai/syllabus-mapping", nvidiaRateLimiter, async (req, res) => { |
| const startTime = Date.now(); |
| try { |
| const userId = await getAuthenticatedUserId(req).catch(() => "anonymous-student"); |
| const { questions, syllabusTerms } = req.body; |
| if (!questions || !syllabusTerms) return res.status(400).json({ error: "Questions list and syllabus terms are required" }); |
|
|
| const systemPrompt = `You are an elite curriculum mapping engine operating Llama 3.1 70B. |
| Map the provided set of questions against the listed core syllabus terms. |
| Your response MUST be valid JSON only: |
| { |
| "mappings": [ |
| { |
| "questionId": "string-id", |
| "mappedTopic": "Matched syllabus term", |
| "confidence": "high" | "medium" | "low", |
| "reasoning": "Reason explanation string" |
| } |
| ] |
| }`; |
|
|
| const rawResponse = await callNvidiaNim('meta/llama-3.1-70b-instruct', systemPrompt, JSON.stringify({ questions, syllabusTerms }), 2000); |
| const parsed = robustJsonParse(rawResponse.trim()); |
|
|
| await logAiOperation(userId, 'syllabus_mapping', 'meta/llama-3.1-70b-instruct', 'NvidiaAIService API call', JSON.stringify({ questions, syllabusTerms }).length, Date.now() - startTime, 'success'); |
| res.json(parsed); |
| } catch (err: any) { |
| console.error("AI Syllabus Mapping Error:", err); |
| res.status(500).json({ error: err.message }); |
| } |
| }); |
|
|
|
|
| async function startServer() { |
| const PORT = 3000; |
|
|
| if (process.env.NODE_ENV !== "production") { |
| const vite = await createViteServer({ |
| server: { middlewareMode: true }, |
| appType: "spa", |
| }); |
| app.use(vite.middlewares); |
| } else { |
| const distPath = path.join(process.cwd(), 'dist'); |
| app.use(express.static(distPath)); |
| app.get('*', (req, res) => { |
| res.sendFile(path.join(distPath, 'index.html')); |
| }); |
| } |
|
|
| app.listen(PORT, "0.0.0.0", () => { |
| console.log(`Server running on http://localhost:${PORT}`); |
| }); |
| } |
|
|
| startServer(); |
|
|