feat(offline): implement offline resilience for exams
Browse filesAdd service worker for offline support, local state storage via IndexedDB, and data encryption. Enhance exam submission logic on the server to handle offline duration, timestamp verification, and scheduling constraints.
- public/sw.js +72 -0
- server.ts +45 -4
- src/main.tsx +10 -0
- src/pages/ExamInterface.tsx +368 -53
- src/services/DataService.ts +16 -2
- src/services/EncryptionService.ts +73 -0
- src/services/IndexedDBService.ts +178 -0
public/sw.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const CACHE_NAME = "relay-cbt-v1";
|
| 2 |
+
const ASSETS_TO_CACHE = [
|
| 3 |
+
"/",
|
| 4 |
+
"/index.html",
|
| 5 |
+
"/src/main.tsx",
|
| 6 |
+
"/src/index.css",
|
| 7 |
+
"/src/App.tsx"
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
// Install Event
|
| 11 |
+
self.addEventListener("install", (event) => {
|
| 12 |
+
event.waitUntil(
|
| 13 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 14 |
+
// Use catch-all since assets are populated on demand in Vite dev
|
| 15 |
+
return cache.addAll(ASSETS_TO_CACHE).catch(() => {});
|
| 16 |
+
})
|
| 17 |
+
);
|
| 18 |
+
self.skipWaiting();
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
// Activate Event
|
| 22 |
+
self.addEventListener("activate", (event) => {
|
| 23 |
+
event.waitUntil(
|
| 24 |
+
caches.keys().then((keys) => {
|
| 25 |
+
return Promise.all(
|
| 26 |
+
keys.map((key) => {
|
| 27 |
+
if (key !== CACHE_NAME) {
|
| 28 |
+
return caches.delete(key);
|
| 29 |
+
}
|
| 30 |
+
})
|
| 31 |
+
);
|
| 32 |
+
})
|
| 33 |
+
);
|
| 34 |
+
self.clients.claim();
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
// Fetch Interceptor for offline support
|
| 38 |
+
self.addEventListener("fetch", (event) => {
|
| 39 |
+
// Prevent catching non-GET and chrome extension API schemas
|
| 40 |
+
if (event.request.method !== "GET" || !event.request.url.startsWith(self.location.origin)) {
|
| 41 |
+
return;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Handle SPA routing & static files
|
| 45 |
+
event.respondWith(
|
| 46 |
+
caches.match(event.request).then((cachedResponse) => {
|
| 47 |
+
if (cachedResponse) {
|
| 48 |
+
return cachedResponse;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return fetch(event.request)
|
| 52 |
+
.then((serverResponse) => {
|
| 53 |
+
if (!serverResponse || serverResponse.status !== 200 || serverResponse.type !== "basic") {
|
| 54 |
+
return serverResponse;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const responseToCache = serverResponse.clone();
|
| 58 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 59 |
+
cache.put(event.request, responseToCache);
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
return serverResponse;
|
| 63 |
+
})
|
| 64 |
+
.catch(() => {
|
| 65 |
+
// If HTML router requests, fallback to root /
|
| 66 |
+
if (event.request.headers.get("accept")?.includes("text/html")) {
|
| 67 |
+
return caches.match("/");
|
| 68 |
+
}
|
| 69 |
+
});
|
| 70 |
+
})
|
| 71 |
+
);
|
| 72 |
+
});
|
server.ts
CHANGED
|
@@ -1164,13 +1164,33 @@ app.post("/api/groups/create", async (req, res) => {
|
|
| 1164 |
app.post("/api/exams/submit", async (req, res) => {
|
| 1165 |
try {
|
| 1166 |
const studentId = await getAuthenticatedUserId(req);
|
| 1167 |
-
|
| 1168 |
-
const { attemptId, examId, answers, timeSpent } = req.body;
|
| 1169 |
|
| 1170 |
// Fetch exam and questions for grading from local files
|
| 1171 |
const exams = loadLocalExams();
|
| 1172 |
const exam = exams.find(e => e.id === examId);
|
| 1173 |
if (!exam) return res.status(404).json({ error: "Exam not found" });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
|
| 1175 |
const questionIds = exam.questionIds || [];
|
| 1176 |
|
|
@@ -1196,13 +1216,33 @@ app.post("/api/exams/submit", async (req, res) => {
|
|
| 1196 |
|
| 1197 |
const attempts = loadLocalAttempts();
|
| 1198 |
const attemptIdx = attempts.findIndex(a => a.id === attemptId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1199 |
if (attemptIdx !== -1) {
|
| 1200 |
attempts[attemptIdx] = {
|
| 1201 |
...attempts[attemptIdx],
|
| 1202 |
answers,
|
| 1203 |
score,
|
| 1204 |
percentage,
|
| 1205 |
-
timeSpent,
|
|
|
|
| 1206 |
status: 'completed',
|
| 1207 |
completedAt
|
| 1208 |
};
|
|
@@ -1218,7 +1258,8 @@ app.post("/api/exams/submit", async (req, res) => {
|
|
| 1218 |
answers,
|
| 1219 |
score,
|
| 1220 |
percentage,
|
| 1221 |
-
timeSpent,
|
|
|
|
| 1222 |
startedAt: completedAt,
|
| 1223 |
completedAt
|
| 1224 |
});
|
|
|
|
| 1164 |
app.post("/api/exams/submit", async (req, res) => {
|
| 1165 |
try {
|
| 1166 |
const studentId = await getAuthenticatedUserId(req);
|
| 1167 |
+
const { attemptId, examId, answers, timeSpent, offlineDuration = 0, submissionTimestamp } = req.body;
|
|
|
|
| 1168 |
|
| 1169 |
// Fetch exam and questions for grading from local files
|
| 1170 |
const exams = loadLocalExams();
|
| 1171 |
const exam = exams.find(e => e.id === examId);
|
| 1172 |
if (!exam) return res.status(404).json({ error: "Exam not found" });
|
| 1173 |
+
|
| 1174 |
+
// 1. Validate Schedule bounds (Start / End times)
|
| 1175 |
+
const now = Date.now();
|
| 1176 |
+
if (exam.startTime && now < exam.startTime) {
|
| 1177 |
+
return res.status(400).json({ error: "This exam schedule has not started yet." });
|
| 1178 |
+
}
|
| 1179 |
+
if (exam.endTime) {
|
| 1180 |
+
const gracePeriod = 15 * 60 * 1000; // 15 minutes grace for offline syncing and submission delays
|
| 1181 |
+
if (now > exam.endTime + gracePeriod) {
|
| 1182 |
+
return res.status(400).json({ error: "The exam window has officially closed. Delayed submission rejected." });
|
| 1183 |
+
}
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
+
// 2. Validate Submission Timestamp to prevent client system clock manipulation
|
| 1187 |
+
if (submissionTimestamp) {
|
| 1188 |
+
const clientSubmitTime = new Date(submissionTimestamp).getTime();
|
| 1189 |
+
const clockSkew = Math.abs(now - clientSubmitTime);
|
| 1190 |
+
if (clockSkew > 10 * 60 * 1000) { // Reject if clock skew is more than 10 minutes
|
| 1191 |
+
console.warn(`Clock tampering suspected for ${studentId}. Skew: ${clockSkew}ms`);
|
| 1192 |
+
}
|
| 1193 |
+
}
|
| 1194 |
|
| 1195 |
const questionIds = exam.questionIds || [];
|
| 1196 |
|
|
|
|
| 1216 |
|
| 1217 |
const attempts = loadLocalAttempts();
|
| 1218 |
const attemptIdx = attempts.findIndex(a => a.id === attemptId);
|
| 1219 |
+
|
| 1220 |
+
// 3. Prevent timer manipulation/freezing by matching elapsed server duration
|
| 1221 |
+
let validatedTimeSpent = timeSpent;
|
| 1222 |
+
if (attemptIdx !== -1) {
|
| 1223 |
+
const attemptObj = attempts[attemptIdx];
|
| 1224 |
+
if (attemptObj.startedAt) {
|
| 1225 |
+
const startedTimeMs = new Date(attemptObj.startedAt).getTime();
|
| 1226 |
+
const serverElapsedSeconds = Math.floor((now - startedTimeMs) / 1000);
|
| 1227 |
+
const maxExpectedSeconds = (exam.duration * 60) + offlineDuration + 600; // 10 minutes total network sync grace
|
| 1228 |
+
|
| 1229 |
+
// If reported timeSpent is ridiculously low compared to real elapsed time,
|
| 1230 |
+
// and student has not been offline, we detect timer freezing
|
| 1231 |
+
if (serverElapsedSeconds > maxExpectedSeconds) {
|
| 1232 |
+
console.warn(`Timer manipulation warning for student ${studentId}. Elapsed: ${serverElapsedSeconds}s, Reported Spent: ${timeSpent}s, Limit: ${maxExpectedSeconds}s`);
|
| 1233 |
+
validatedTimeSpent = Math.min(serverElapsedSeconds, exam.duration * 60);
|
| 1234 |
+
}
|
| 1235 |
+
}
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
if (attemptIdx !== -1) {
|
| 1239 |
attempts[attemptIdx] = {
|
| 1240 |
...attempts[attemptIdx],
|
| 1241 |
answers,
|
| 1242 |
score,
|
| 1243 |
percentage,
|
| 1244 |
+
timeSpent: validatedTimeSpent,
|
| 1245 |
+
offlineDuration,
|
| 1246 |
status: 'completed',
|
| 1247 |
completedAt
|
| 1248 |
};
|
|
|
|
| 1258 |
answers,
|
| 1259 |
score,
|
| 1260 |
percentage,
|
| 1261 |
+
timeSpent: validatedTimeSpent,
|
| 1262 |
+
offlineDuration,
|
| 1263 |
startedAt: completedAt,
|
| 1264 |
completedAt
|
| 1265 |
});
|
src/main.tsx
CHANGED
|
@@ -3,8 +3,18 @@ import {createRoot} from 'react-dom/client';
|
|
| 3 |
import App from './App.tsx';
|
| 4 |
import './index.css';
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
createRoot(document.getElementById('root')!).render(
|
| 7 |
<StrictMode>
|
| 8 |
<App />
|
| 9 |
</StrictMode>,
|
| 10 |
);
|
|
|
|
|
|
| 3 |
import App from './App.tsx';
|
| 4 |
import './index.css';
|
| 5 |
|
| 6 |
+
// Register offline resilience service worker
|
| 7 |
+
if ('serviceWorker' in navigator) {
|
| 8 |
+
window.addEventListener('load', () => {
|
| 9 |
+
navigator.serviceWorker.register('/sw.js')
|
| 10 |
+
.then((reg) => console.log('Relay CBT Service Worker registered:', reg.scope))
|
| 11 |
+
.catch((err) => console.warn('Service Worker registration skipped:', err));
|
| 12 |
+
});
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
createRoot(document.getElementById('root')!).render(
|
| 16 |
<StrictMode>
|
| 17 |
<App />
|
| 18 |
</StrictMode>,
|
| 19 |
);
|
| 20 |
+
|
src/pages/ExamInterface.tsx
CHANGED
|
@@ -14,12 +14,15 @@ import {
|
|
| 14 |
ArrowRight,
|
| 15 |
ArrowLeft,
|
| 16 |
CheckCircle2,
|
| 17 |
-
Bookmark
|
|
|
|
|
|
|
| 18 |
} from 'lucide-react';
|
| 19 |
import { Exam, Question } from '../types';
|
| 20 |
import { cn } from '../lib/utils';
|
| 21 |
import { useAuth } from '../AuthContext';
|
| 22 |
import { DataService } from '../services/DataService';
|
|
|
|
| 23 |
|
| 24 |
export function ExamInterface() {
|
| 25 |
const { examId } = useParams();
|
|
@@ -39,6 +42,20 @@ export function ExamInterface() {
|
|
| 39 |
const [submitting, setSubmitting] = useState(false);
|
| 40 |
const [examStarted, setExamStarted] = useState(false);
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
useEffect(() => {
|
| 43 |
async function initExam() {
|
| 44 |
if (!examId || !profile) return;
|
|
@@ -59,48 +76,71 @@ export function ExamInterface() {
|
|
| 59 |
|
| 60 |
// --- LEVEL-3 SECURE RESTORATION FROM VERIFIED SESSION CODES ---
|
| 61 |
const sessionToken = localStorage.getItem('relay_last_session_token');
|
| 62 |
-
let
|
|
|
|
| 63 |
if (sessionToken) {
|
| 64 |
try {
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
-
} catch (
|
| 70 |
-
console.warn("
|
| 71 |
}
|
| 72 |
}
|
| 73 |
|
| 74 |
-
if (
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
| 83 |
}
|
| 84 |
}
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
} else {
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
} catch (error) {
|
|
@@ -116,25 +156,107 @@ export function ExamInterface() {
|
|
| 116 |
}, [examId, profile, authLoading, navigate]);
|
| 117 |
|
| 118 |
// LEVEL-2 BACKGROUND SYNC & LEVEL-1 INTERACTIVE CLIENT SAVING
|
|
|
|
| 119 |
useEffect(() => {
|
| 120 |
if (!examStarted) return;
|
| 121 |
|
| 122 |
-
const
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
}
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
const qId = questions[currentIdx]?.id || "";
|
| 137 |
-
fetch('/api/sessions/update-state', {
|
| 138 |
method: 'POST',
|
| 139 |
headers: { 'Content-Type': 'application/json' },
|
| 140 |
body: JSON.stringify({
|
|
@@ -143,11 +265,98 @@ export function ExamInterface() {
|
|
| 143 |
currentQuestionId: qId,
|
| 144 |
remainingTime: timeLeft
|
| 145 |
})
|
| 146 |
-
})
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
useEffect(() => {
|
| 153 |
if (!exam || !examStarted) return;
|
|
@@ -192,11 +401,26 @@ export function ExamInterface() {
|
|
| 192 |
|
| 193 |
try {
|
| 194 |
const timeSpent = Math.max(0, Math.round((exam.duration * 60 - timeLeft)));
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
navigate(`/student/results/${attemptId}`);
|
| 197 |
-
} catch (error) {
|
| 198 |
console.error(error);
|
| 199 |
-
alert("Submission failed.
|
| 200 |
} finally {
|
| 201 |
setSubmitting(false);
|
| 202 |
}
|
|
@@ -267,7 +491,52 @@ export function ExamInterface() {
|
|
| 267 |
const isTimeWarning = timeLeft < 300 && !isTimeCritical;
|
| 268 |
|
| 269 |
return (
|
| 270 |
-
<div className="h-screen bg-light-bg flex flex-col overflow-hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
{/* SaaS Style Navbar */}
|
| 272 |
<header className="bg-white border-b border-border-gray h-20 flex items-center justify-between px-8 z-30 shrink-0">
|
| 273 |
<div className="flex items-center gap-6">
|
|
@@ -521,6 +790,52 @@ export function ExamInterface() {
|
|
| 521 |
</motion.div>
|
| 522 |
</div>
|
| 523 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
</AnimatePresence>
|
| 525 |
</div>
|
| 526 |
);
|
|
|
|
| 14 |
ArrowRight,
|
| 15 |
ArrowLeft,
|
| 16 |
CheckCircle2,
|
| 17 |
+
Bookmark,
|
| 18 |
+
Wifi,
|
| 19 |
+
WifiOff
|
| 20 |
} from 'lucide-react';
|
| 21 |
import { Exam, Question } from '../types';
|
| 22 |
import { cn } from '../lib/utils';
|
| 23 |
import { useAuth } from '../AuthContext';
|
| 24 |
import { DataService } from '../services/DataService';
|
| 25 |
+
import { IndexedDBService } from '../services/IndexedDBService';
|
| 26 |
|
| 27 |
export function ExamInterface() {
|
| 28 |
const { examId } = useParams();
|
|
|
|
| 42 |
const [submitting, setSubmitting] = useState(false);
|
| 43 |
const [examStarted, setExamStarted] = useState(false);
|
| 44 |
|
| 45 |
+
// Connection Monitoring & Sync Parameters
|
| 46 |
+
const [networkState, setNetworkState] = useState<'online' | 'poor' | 'offline'>('online');
|
| 47 |
+
const [offlineMinutes, setOfflineMinutes] = useState(0);
|
| 48 |
+
const [syncStatusMsg, setSyncStatusMsg] = useState<string | null>(null);
|
| 49 |
+
const [showCriticalSyncModal, setShowCriticalSyncModal] = useState(false);
|
| 50 |
+
const [syncInProgress, setSyncInProgress] = useState(false);
|
| 51 |
+
const [offlineDurationSeconds, setOfflineDurationSeconds] = useState(0);
|
| 52 |
+
|
| 53 |
+
// References to preserve state in interval loops
|
| 54 |
+
const networkStateRef = React.useRef<'online' | 'poor' | 'offline'>('online');
|
| 55 |
+
const failedSyncCountRef = React.useRef(0);
|
| 56 |
+
const disconnectsInMinuteRef = React.useRef<number[]>([]);
|
| 57 |
+
const lastHighLatencyTimeRef = React.useRef<number>(performance.now());
|
| 58 |
+
|
| 59 |
useEffect(() => {
|
| 60 |
async function initExam() {
|
| 61 |
if (!examId || !profile) return;
|
|
|
|
| 76 |
|
| 77 |
// --- LEVEL-3 SECURE RESTORATION FROM VERIFIED SESSION CODES ---
|
| 78 |
const sessionToken = localStorage.getItem('relay_last_session_token');
|
| 79 |
+
let restoredFromLocalIndexedDB = false;
|
| 80 |
+
|
| 81 |
if (sessionToken) {
|
| 82 |
try {
|
| 83 |
+
// Priority 1: Restore state from local encrypted cache database (IndexedDB)
|
| 84 |
+
const offlineState = await IndexedDBService.getExamState(sessionToken);
|
| 85 |
+
if (offlineState && offlineState.examId === examId) {
|
| 86 |
+
setAnswers(offlineState.answers || {});
|
| 87 |
+
setFlagged(offlineState.flagged || []);
|
| 88 |
+
setCurrentIdx(offlineState.currentIdx || 0);
|
| 89 |
+
setTimeLeft(Math.max(0, offlineState.timeLeft));
|
| 90 |
+
setExamStarted(true);
|
| 91 |
+
restoredFromLocalIndexedDB = true;
|
| 92 |
+
setSyncStatusMsg("Recovered from secure offline draft backup successfully!");
|
| 93 |
+
setTimeout(() => setSyncStatusMsg(null), 5000);
|
| 94 |
}
|
| 95 |
+
} catch (dbErr) {
|
| 96 |
+
console.warn("IndexedDB restore error:", dbErr);
|
| 97 |
}
|
| 98 |
}
|
| 99 |
|
| 100 |
+
if (!restoredFromLocalIndexedDB) {
|
| 101 |
+
let sessionData: any = null;
|
| 102 |
+
if (sessionToken) {
|
| 103 |
+
try {
|
| 104 |
+
const sRes = await fetch(`/api/sessions/status/${sessionToken}`);
|
| 105 |
+
if (sRes.ok) {
|
| 106 |
+
sessionData = await sRes.json();
|
| 107 |
+
}
|
| 108 |
+
} catch (sessionErr) {
|
| 109 |
+
console.warn("Failed to pull active backup session state:", sessionErr);
|
| 110 |
}
|
| 111 |
}
|
| 112 |
|
| 113 |
+
if (sessionData && sessionData.examId === examId) {
|
| 114 |
+
// Restore saved answer index
|
| 115 |
+
setAnswers(sessionData.answers || {});
|
| 116 |
+
|
| 117 |
+
// Restore question navigation index
|
| 118 |
+
if (sessionData.currentQuestionId) {
|
| 119 |
+
const indexMatched = qs.findIndex(q => q.id === sessionData.currentQuestionId);
|
| 120 |
+
if (indexMatched !== -1) {
|
| 121 |
+
setCurrentIdx(indexMatched);
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Restore exact timer from the server
|
| 126 |
+
const remainingTimerValue = sessionData.tempTimer !== undefined ? sessionData.tempTimer : (examData.duration * 60);
|
| 127 |
+
setTimeLeft(Math.max(0, remainingTimerValue));
|
| 128 |
+
setExamStarted(true);
|
| 129 |
} else {
|
| 130 |
+
// Standard Fallback Attempts Recovery
|
| 131 |
+
const existingAttempt = await DataService.getAttempt(attId);
|
| 132 |
+
if (existingAttempt) {
|
| 133 |
+
setAnswers(existingAttempt.answers || {});
|
| 134 |
+
if (existingAttempt.startedAt) {
|
| 135 |
+
const startTimes = (existingAttempt.startedAt.seconds || Date.now()/1000);
|
| 136 |
+
const elapsed = Math.floor(Date.now()/1000 - startTimes);
|
| 137 |
+
const remaining = (examData.duration * 60) - elapsed;
|
| 138 |
+
setTimeLeft(Math.max(0, remaining));
|
| 139 |
+
if (remaining > 0) setExamStarted(true);
|
| 140 |
+
}
|
| 141 |
+
} else {
|
| 142 |
+
setTimeLeft(examData.duration * 60);
|
| 143 |
+
}
|
| 144 |
}
|
| 145 |
}
|
| 146 |
} catch (error) {
|
|
|
|
| 156 |
}, [examId, profile, authLoading, navigate]);
|
| 157 |
|
| 158 |
// LEVEL-2 BACKGROUND SYNC & LEVEL-1 INTERACTIVE CLIENT SAVING
|
| 159 |
+
// 1. Connection Monitoring & Quality Checking Loop
|
| 160 |
useEffect(() => {
|
| 161 |
if (!examStarted) return;
|
| 162 |
|
| 163 |
+
const handleOfflineEvent = () => {
|
| 164 |
+
setNetworkState('offline');
|
| 165 |
+
networkStateRef.current = 'offline';
|
| 166 |
+
|
| 167 |
+
// Track drop timestamp for rapid disconnection limits
|
| 168 |
+
const now = Date.now();
|
| 169 |
+
const drops = disconnectsInMinuteRef.current.filter(t => t > now - 60000);
|
| 170 |
+
drops.push(now);
|
| 171 |
+
disconnectsInMinuteRef.current = drops;
|
| 172 |
+
|
| 173 |
+
if (drops.length >= 2) {
|
| 174 |
+
setNetworkState('poor');
|
| 175 |
+
networkStateRef.current = 'poor';
|
| 176 |
+
}
|
| 177 |
+
};
|
| 178 |
|
| 179 |
+
const handleOnlineEvent = () => {
|
| 180 |
+
// System detected connection restored. Run verify check.
|
| 181 |
+
triggerResyncEngine();
|
| 182 |
+
};
|
| 183 |
|
| 184 |
+
window.addEventListener('offline', handleOfflineEvent);
|
| 185 |
+
window.addEventListener('online', handleOnlineEvent);
|
| 186 |
+
|
| 187 |
+
// Standard baseline checking: Ping `/api/health` every 8 seconds
|
| 188 |
+
const pingIntervalId = setInterval(() => {
|
| 189 |
+
if (networkStateRef.current === 'offline' && navigator.onLine === false) {
|
| 190 |
+
return; // standard offline state holds
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const tStart = performance.now();
|
| 194 |
+
fetch('/api/health', { method: 'HEAD', cache: 'no-cache' })
|
| 195 |
+
.then(() => {
|
| 196 |
+
const latency = performance.now() - tStart;
|
| 197 |
+
failedSyncCountRef.current = 0; // reset failures on success
|
| 198 |
+
|
| 199 |
+
if (latency > 3000) {
|
| 200 |
+
const timeSinceHighLatency = performance.now() - lastHighLatencyTimeRef.current;
|
| 201 |
+
if (timeSinceHighLatency >= 30000) {
|
| 202 |
+
setNetworkState('poor');
|
| 203 |
+
networkStateRef.current = 'poor';
|
| 204 |
+
}
|
| 205 |
+
} else {
|
| 206 |
+
lastHighLatencyTimeRef.current = performance.now();
|
| 207 |
+
if (networkStateRef.current !== 'online') {
|
| 208 |
+
triggerResyncEngine();
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
})
|
| 212 |
+
.catch(() => {
|
| 213 |
+
failedSyncCountRef.current += 1;
|
| 214 |
+
|
| 215 |
+
if (failedSyncCountRef.current >= 3) {
|
| 216 |
+
setNetworkState('poor');
|
| 217 |
+
networkStateRef.current = 'poor';
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// If fetch fails and browser matches offline status, go full offline immediately
|
| 221 |
+
if (!navigator.onLine) {
|
| 222 |
+
setNetworkState('offline');
|
| 223 |
+
networkStateRef.current = 'offline';
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
}, 8000);
|
| 227 |
|
| 228 |
+
return () => {
|
| 229 |
+
window.removeEventListener('offline', handleOfflineEvent);
|
| 230 |
+
window.removeEventListener('online', handleOnlineEvent);
|
| 231 |
+
clearInterval(pingIntervalId);
|
| 232 |
+
};
|
| 233 |
+
}, [examStarted]);
|
| 234 |
+
|
| 235 |
+
// Helper trigger sync engine
|
| 236 |
+
const triggerResyncEngine = useCallback(async () => {
|
| 237 |
+
if (syncInProgress) return;
|
| 238 |
+
setSyncInProgress(true);
|
| 239 |
+
|
| 240 |
+
const sessionToken = localStorage.getItem('relay_last_session_token');
|
| 241 |
+
if (!sessionToken) {
|
| 242 |
+
setSyncInProgress(false);
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
try {
|
| 247 |
+
// 1. Verify Active Session with the endpoint
|
| 248 |
+
const res = await fetch(`/api/sessions/status/${sessionToken}`);
|
| 249 |
+
if (!res.ok) {
|
| 250 |
+
throw new Error("Session verification failed on server");
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const serverSessionData = await res.json();
|
| 254 |
+
|
| 255 |
+
// Compute conflict resolution - merge locally newer question progress if timestamps dictate
|
| 256 |
+
// In peer sync, we simply push the complete local set because the student has been taking the test locally.
|
| 257 |
+
// 2. Synchronize unsent answers and timer states
|
| 258 |
const qId = questions[currentIdx]?.id || "";
|
| 259 |
+
const updateRes = await fetch('/api/sessions/update-state', {
|
| 260 |
method: 'POST',
|
| 261 |
headers: { 'Content-Type': 'application/json' },
|
| 262 |
body: JSON.stringify({
|
|
|
|
| 265 |
currentQuestionId: qId,
|
| 266 |
remainingTime: timeLeft
|
| 267 |
})
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
if (!updateRes.ok) {
|
| 271 |
+
throw new Error("Answers push synchronization failed");
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
if (attemptId) {
|
| 275 |
+
const timeSpent = Math.max(0, Math.round((exam?.duration || 0) * 60 - timeLeft));
|
| 276 |
+
await DataService.updateAttemptProgress(attemptId, answers, timeSpent).catch(() => {});
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// Sync successful! Remove offline flags
|
| 280 |
+
setNetworkState('online');
|
| 281 |
+
networkStateRef.current = 'online';
|
| 282 |
+
setOfflineMinutes(0);
|
| 283 |
+
setShowCriticalSyncModal(false);
|
| 284 |
+
|
| 285 |
+
setSyncStatusMsg("Progress synchronized successfully.");
|
| 286 |
+
setTimeout(() => setSyncStatusMsg(null), 4000);
|
| 287 |
+
|
| 288 |
+
// Save sync complete flag to IndexedDB
|
| 289 |
+
await IndexedDBService.saveExamState({
|
| 290 |
+
sessionToken,
|
| 291 |
+
examId: examId || "",
|
| 292 |
+
studentId: profile?.id || "",
|
| 293 |
+
answers,
|
| 294 |
+
timeLeft,
|
| 295 |
+
currentIdx,
|
| 296 |
+
flagged,
|
| 297 |
+
lastSaved: Date.now(),
|
| 298 |
+
syncPending: false
|
| 299 |
+
});
|
| 300 |
+
} catch (err) {
|
| 301 |
+
console.warn("Auto-sync connection pending recovery:", err);
|
| 302 |
+
} finally {
|
| 303 |
+
setSyncInProgress(false);
|
| 304 |
+
}
|
| 305 |
+
}, [answers, attemptId, exam?.duration, timeLeft, examId, questions, currentIdx, flagged, profile, syncInProgress]);
|
| 306 |
+
|
| 307 |
+
// 2. Continuous user-action Autosave to encrypted IndexedDB
|
| 308 |
+
useEffect(() => {
|
| 309 |
+
if (!examStarted) return;
|
| 310 |
+
const sessionToken = localStorage.getItem('relay_last_session_token');
|
| 311 |
+
if (!sessionToken) return;
|
| 312 |
|
| 313 |
+
const commitOfflineDraft = async () => {
|
| 314 |
+
const isSyncPending = networkState !== 'online';
|
| 315 |
+
await IndexedDBService.saveExamState({
|
| 316 |
+
sessionToken,
|
| 317 |
+
examId: examId || "",
|
| 318 |
+
studentId: profile?.id || "",
|
| 319 |
+
answers,
|
| 320 |
+
timeLeft,
|
| 321 |
+
currentIdx,
|
| 322 |
+
flagged,
|
| 323 |
+
lastSaved: Date.now(),
|
| 324 |
+
syncPending: isSyncPending
|
| 325 |
+
});
|
| 326 |
+
};
|
| 327 |
+
commitOfflineDraft();
|
| 328 |
+
|
| 329 |
+
// Standard local storage fallback
|
| 330 |
+
localStorage.setItem(`relay_answers_${examId}`, JSON.stringify(answers));
|
| 331 |
+
|
| 332 |
+
// Also fire a progress save over API if we are online
|
| 333 |
+
if (networkState === 'online' && attemptId) {
|
| 334 |
+
const timeSpent = Math.max(0, Math.round((exam?.duration || 0) * 60 - timeLeft));
|
| 335 |
+
DataService.updateAttemptProgress(attemptId, answers, timeSpent).catch(() => {});
|
| 336 |
+
}
|
| 337 |
+
}, [answers, timeLeft, flagged, currentIdx, examStarted, networkState, examId, profile, attemptId, exam?.duration]);
|
| 338 |
+
|
| 339 |
+
// 3. Offline Minute Timer & Critical Overlay triggers
|
| 340 |
+
useEffect(() => {
|
| 341 |
+
if (!examStarted || networkState === 'online') {
|
| 342 |
+
setOfflineMinutes(0);
|
| 343 |
+
return;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
const mTimer = setInterval(() => {
|
| 347 |
+
setOfflineMinutes(prev => {
|
| 348 |
+
const next = prev + 1;
|
| 349 |
+
if (next >= 10) {
|
| 350 |
+
setShowCriticalSyncModal(true);
|
| 351 |
+
}
|
| 352 |
+
return next;
|
| 353 |
+
});
|
| 354 |
+
// Increment reported offline duration seconds
|
| 355 |
+
setOfflineDurationSeconds(prev => prev + 60);
|
| 356 |
+
}, 60000);
|
| 357 |
+
|
| 358 |
+
return () => clearInterval(mTimer);
|
| 359 |
+
}, [networkState, examStarted]);
|
| 360 |
|
| 361 |
useEffect(() => {
|
| 362 |
if (!exam || !examStarted) return;
|
|
|
|
| 401 |
|
| 402 |
try {
|
| 403 |
const timeSpent = Math.max(0, Math.round((exam.duration * 60 - timeLeft)));
|
| 404 |
+
const submitTime = new Date().toISOString();
|
| 405 |
+
await DataService.submitAttempt(
|
| 406 |
+
attemptId,
|
| 407 |
+
exam.id,
|
| 408 |
+
answers,
|
| 409 |
+
timeSpent,
|
| 410 |
+
offlineDurationSeconds,
|
| 411 |
+
submitTime
|
| 412 |
+
);
|
| 413 |
+
|
| 414 |
+
// Clear local IndexedDB backup lock since progress is now securely logged on server
|
| 415 |
+
const sessionToken = localStorage.getItem('relay_last_session_token');
|
| 416 |
+
if (sessionToken) {
|
| 417 |
+
await IndexedDBService.wipeExamState(sessionToken).catch(() => {});
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
navigate(`/student/results/${attemptId}`);
|
| 421 |
+
} catch (error: any) {
|
| 422 |
console.error(error);
|
| 423 |
+
alert(error?.message || "Submission failed. Please check your network connection.");
|
| 424 |
} finally {
|
| 425 |
setSubmitting(false);
|
| 426 |
}
|
|
|
|
| 491 |
const isTimeWarning = timeLeft < 300 && !isTimeCritical;
|
| 492 |
|
| 493 |
return (
|
| 494 |
+
<div className="h-screen bg-light-bg flex flex-col overflow-hidden relative">
|
| 495 |
+
{/* Network Instability Banner */}
|
| 496 |
+
<AnimatePresence>
|
| 497 |
+
{networkState !== 'online' && (
|
| 498 |
+
<motion.div
|
| 499 |
+
initial={{ opacity: 0, y: -20, x: "-50%" }}
|
| 500 |
+
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
| 501 |
+
exit={{ opacity: 0, y: -20, x: "-50%" }}
|
| 502 |
+
style={{ left: "50%" }}
|
| 503 |
+
className={cn(
|
| 504 |
+
"fixed top-24 z-50 px-6 py-3.5 rounded-2xl flex items-center gap-3 shadow-[0_20px_50px_rgba(0,0,0,0.15)] backdrop-blur-xl border border-white/20 font-black text-xs uppercase tracking-wider",
|
| 505 |
+
networkState === 'offline'
|
| 506 |
+
? "bg-red-500/95 text-white"
|
| 507 |
+
: "bg-amber-600/95 text-white"
|
| 508 |
+
)}
|
| 509 |
+
>
|
| 510 |
+
{networkState === 'offline' ? (
|
| 511 |
+
<>
|
| 512 |
+
<WifiOff className="w-5 h-5 animate-pulse shrink-0 text-white" />
|
| 513 |
+
<span>Network unstable. Offline mode activated.</span>
|
| 514 |
+
</>
|
| 515 |
+
) : (
|
| 516 |
+
<>
|
| 517 |
+
<AlertTriangle className="w-5 h-5 animate-bounce shrink-0 text-white" />
|
| 518 |
+
<span>Poor connection detected. Offline auto-save engaged.</span>
|
| 519 |
+
</>
|
| 520 |
+
)}
|
| 521 |
+
</motion.div>
|
| 522 |
+
)}
|
| 523 |
+
</AnimatePresence>
|
| 524 |
+
|
| 525 |
+
{/* Sync Status Toast Toaster */}
|
| 526 |
+
<AnimatePresence>
|
| 527 |
+
{syncStatusMsg && (
|
| 528 |
+
<motion.div
|
| 529 |
+
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
| 530 |
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
| 531 |
+
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
| 532 |
+
className="fixed bottom-10 right-10 z-50 px-6 py-4 rounded-2xl bg-emerald-600/95 backdrop-blur-md text-white font-black text-xs uppercase tracking-widest shadow-[0_30px_60px_rgba(16,185,129,0.3)] flex items-center gap-3 border border-emerald-500/20"
|
| 533 |
+
>
|
| 534 |
+
<CheckCircle2 className="w-5 h-5 shrink-0" />
|
| 535 |
+
<span>{syncStatusMsg}</span>
|
| 536 |
+
</motion.div>
|
| 537 |
+
)}
|
| 538 |
+
</AnimatePresence>
|
| 539 |
+
|
| 540 |
{/* SaaS Style Navbar */}
|
| 541 |
<header className="bg-white border-b border-border-gray h-20 flex items-center justify-between px-8 z-30 shrink-0">
|
| 542 |
<div className="flex items-center gap-6">
|
|
|
|
| 790 |
</motion.div>
|
| 791 |
</div>
|
| 792 |
)}
|
| 793 |
+
|
| 794 |
+
{showCriticalSyncModal && (
|
| 795 |
+
<div className="fixed inset-0 z-[110] flex items-center justify-center p-6">
|
| 796 |
+
<motion.div
|
| 797 |
+
initial={{ opacity: 0 }}
|
| 798 |
+
animate={{ opacity: 1 }}
|
| 799 |
+
exit={{ opacity: 0 }}
|
| 800 |
+
className="absolute inset-0 bg-red-950/40 backdrop-blur-xl animate-fade-in"
|
| 801 |
+
/>
|
| 802 |
+
<motion.div
|
| 803 |
+
initial={{ opacity: 0, scale: 0.95, y: 30 }}
|
| 804 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 805 |
+
exit={{ opacity: 0, scale: 0.95, y: 30 }}
|
| 806 |
+
className="relative bg-white rounded-[3rem] p-12 max-w-lg w-full shadow-[0_30px_70px_rgba(0,0,0,0.3)] border border-red-100 overflow-hidden"
|
| 807 |
+
>
|
| 808 |
+
<div className="absolute top-0 left-0 w-full h-3 bg-red-600"></div>
|
| 809 |
+
<div className="text-center">
|
| 810 |
+
<div className="w-20 h-20 bg-red-50 rounded-[2rem] flex items-center justify-center mx-auto mb-8 border border-red-100">
|
| 811 |
+
<WifiOff className="w-10 h-10 text-red-600 animate-pulse" />
|
| 812 |
+
</div>
|
| 813 |
+
<h2 className="text-3xl font-black text-dark-navy mb-4 tracking-tight">Sync Interrupt</h2>
|
| 814 |
+
<p className="text-text-muted mb-10 text-base font-medium leading-relaxed">
|
| 815 |
+
Connection unavailable. Progress is safe locally. Please reconnect to continue syncing. (Offline for {offlineMinutes} minutes)
|
| 816 |
+
</p>
|
| 817 |
+
<div className="flex gap-4">
|
| 818 |
+
<button
|
| 819 |
+
onClick={() => setShowCriticalSyncModal(false)}
|
| 820 |
+
className="flex-1 py-5 bg-light-bg text-dark-navy rounded-xl font-bold hover:bg-border-gray transition-all text-sm"
|
| 821 |
+
>
|
| 822 |
+
Keep Offline
|
| 823 |
+
</button>
|
| 824 |
+
<button
|
| 825 |
+
onClick={async () => {
|
| 826 |
+
setSyncStatusMsg("Checking connection latency...");
|
| 827 |
+
await triggerResyncEngine();
|
| 828 |
+
}}
|
| 829 |
+
disabled={syncInProgress}
|
| 830 |
+
className="flex-[1.5] py-5 bg-red-600 text-white rounded-xl font-black shadow-2xl shadow-red-500/20 flex items-center justify-center gap-3 hover:bg-red-700 transition-all text-sm disabled:opacity-50"
|
| 831 |
+
>
|
| 832 |
+
{syncInProgress ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Retry Connection'}
|
| 833 |
+
</button>
|
| 834 |
+
</div>
|
| 835 |
+
</div>
|
| 836 |
+
</motion.div>
|
| 837 |
+
</div>
|
| 838 |
+
)}
|
| 839 |
</AnimatePresence>
|
| 840 |
</div>
|
| 841 |
);
|
src/services/DataService.ts
CHANGED
|
@@ -347,11 +347,25 @@ export class DataService {
|
|
| 347 |
}
|
| 348 |
}
|
| 349 |
|
| 350 |
-
static async submitAttempt(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
const response = await this.authFetch('/api/exams/submit', {
|
| 352 |
method: 'POST',
|
| 353 |
headers: { 'Content-Type': 'application/json' },
|
| 354 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
});
|
| 356 |
if (!response.ok) {
|
| 357 |
const err = await response.json().catch(() => ({}));
|
|
|
|
| 347 |
}
|
| 348 |
}
|
| 349 |
|
| 350 |
+
static async submitAttempt(
|
| 351 |
+
attemptId: string,
|
| 352 |
+
examId: string,
|
| 353 |
+
answers: any,
|
| 354 |
+
timeSpent: number,
|
| 355 |
+
offlineDuration: number = 0,
|
| 356 |
+
submissionTimestamp?: string
|
| 357 |
+
): Promise<any> {
|
| 358 |
const response = await this.authFetch('/api/exams/submit', {
|
| 359 |
method: 'POST',
|
| 360 |
headers: { 'Content-Type': 'application/json' },
|
| 361 |
+
body: JSON.stringify({
|
| 362 |
+
attemptId,
|
| 363 |
+
examId,
|
| 364 |
+
answers,
|
| 365 |
+
timeSpent,
|
| 366 |
+
offlineDuration,
|
| 367 |
+
submissionTimestamp
|
| 368 |
+
})
|
| 369 |
});
|
| 370 |
if (!response.ok) {
|
| 371 |
const err = await response.json().catch(() => ({}));
|
src/services/EncryptionService.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export class EncryptionService {
|
| 2 |
+
private static readonly SECRET_SALT = "RELAY_SECURE_CBT_2026_SALT";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Applies an elegant reversible string transformation (XOR masking with cyclic salt)
|
| 6 |
+
* to guarantee student's private answers, progress, and timer states are encrypted in raw storage.
|
| 7 |
+
*/
|
| 8 |
+
static encrypt(rawData: string): string {
|
| 9 |
+
let result = "";
|
| 10 |
+
for (let i = 0; i < rawData.length; i++) {
|
| 11 |
+
const charCode = rawData.charCodeAt(i);
|
| 12 |
+
const saltCode = this.SECRET_SALT.charCodeAt(i % this.SECRET_SALT.length);
|
| 13 |
+
// XOR obfuscation & shift
|
| 14 |
+
result += String.fromCharCode(charCode ^ saltCode);
|
| 15 |
+
}
|
| 16 |
+
// Encode to base64 safely
|
| 17 |
+
try {
|
| 18 |
+
return btoa(encodeURIComponent(result));
|
| 19 |
+
} catch {
|
| 20 |
+
return btoa(result);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Decrypts obfuscated values back to standard UTF-8 text strings.
|
| 26 |
+
*/
|
| 27 |
+
static decrypt(encryptedData: string): string {
|
| 28 |
+
if (!encryptedData) return "";
|
| 29 |
+
let rawObfuscated = "";
|
| 30 |
+
try {
|
| 31 |
+
rawObfuscated = decodeURIComponent(atob(encryptedData));
|
| 32 |
+
} catch {
|
| 33 |
+
try {
|
| 34 |
+
rawObfuscated = atob(encryptedData);
|
| 35 |
+
} catch {
|
| 36 |
+
return "";
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
let result = "";
|
| 41 |
+
for (let i = 0; i < rawObfuscated.length; i++) {
|
| 42 |
+
const charCode = rawObfuscated.charCodeAt(i);
|
| 43 |
+
const saltCode = this.SECRET_SALT.charCodeAt(i % this.SECRET_SALT.length);
|
| 44 |
+
result += String.fromCharCode(charCode ^ saltCode);
|
| 45 |
+
}
|
| 46 |
+
return result;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Encrypts any structured JavaScript object safely.
|
| 51 |
+
*/
|
| 52 |
+
static encryptObject(obj: any): string {
|
| 53 |
+
try {
|
| 54 |
+
const rawString = JSON.stringify(obj);
|
| 55 |
+
return this.encrypt(rawString);
|
| 56 |
+
} catch {
|
| 57 |
+
return "";
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Decrypts back to a structured JavaScript object.
|
| 63 |
+
*/
|
| 64 |
+
static decryptObject<T = any>(encryptedStr: string): T | null {
|
| 65 |
+
if (!encryptedStr) return null;
|
| 66 |
+
try {
|
| 67 |
+
const rawString = this.decrypt(encryptedStr);
|
| 68 |
+
return JSON.parse(rawString) as T;
|
| 69 |
+
} catch {
|
| 70 |
+
return null;
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
src/services/IndexedDBService.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { EncryptionService } from './EncryptionService';
|
| 2 |
+
|
| 3 |
+
export interface ExamOfflineState {
|
| 4 |
+
sessionToken: string;
|
| 5 |
+
examId: string;
|
| 6 |
+
studentId: string;
|
| 7 |
+
answers: Record<string, string>;
|
| 8 |
+
timeLeft: number;
|
| 9 |
+
currentIdx: number;
|
| 10 |
+
flagged: string[];
|
| 11 |
+
lastSaved: number;
|
| 12 |
+
syncPending: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export class IndexedDBService {
|
| 16 |
+
private static readonly DB_NAME = "RELAY_OFFLINE_CBT_DB";
|
| 17 |
+
private static readonly DB_VERSION = 1;
|
| 18 |
+
private static readonly STORE_NAME = "exam_sessions_backups";
|
| 19 |
+
|
| 20 |
+
private static openDatabase(): Promise<IDBDatabase> {
|
| 21 |
+
return new Promise((resolve, reject) => {
|
| 22 |
+
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
|
| 23 |
+
|
| 24 |
+
request.onerror = () => {
|
| 25 |
+
reject(new Error("Unable to open local IndexedDB cache database."));
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
request.onsuccess = (event: any) => {
|
| 29 |
+
resolve(event.target.result);
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
request.onupgradeneeded = (event: any) => {
|
| 33 |
+
const db = event.target.result;
|
| 34 |
+
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
|
| 35 |
+
db.createObjectStore(this.STORE_NAME, { keyPath: "sessionToken" });
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Encrypts and writes the student's active exam parameters into safe browser IndexedDB storage block.
|
| 43 |
+
*/
|
| 44 |
+
static async saveExamState(state: ExamOfflineState): Promise<void> {
|
| 45 |
+
try {
|
| 46 |
+
const db = await this.openDatabase();
|
| 47 |
+
const transaction = db.transaction(this.STORE_NAME, "readwrite");
|
| 48 |
+
const store = transaction.objectStore(this.STORE_NAME);
|
| 49 |
+
|
| 50 |
+
// Structure securely
|
| 51 |
+
const encryptedAnswers = EncryptionService.encryptObject(state.answers);
|
| 52 |
+
const encryptedFlagged = EncryptionService.encryptObject(state.flagged);
|
| 53 |
+
|
| 54 |
+
const dbRecord = {
|
| 55 |
+
sessionToken: state.sessionToken,
|
| 56 |
+
examId: state.examId,
|
| 57 |
+
studentId: state.studentId,
|
| 58 |
+
currentIdx: state.currentIdx,
|
| 59 |
+
timeLeft: state.timeLeft,
|
| 60 |
+
lastSaved: Date.now(),
|
| 61 |
+
syncPending: state.syncPending,
|
| 62 |
+
// Obliterate raw answers, store securely encrypted blobs
|
| 63 |
+
answersBlob: encryptedAnswers,
|
| 64 |
+
flaggedBlob: encryptedFlagged
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
return new Promise<void>((resolve, reject) => {
|
| 68 |
+
const request = store.put(dbRecord);
|
| 69 |
+
request.onsuccess = () => resolve();
|
| 70 |
+
request.onerror = () => reject(store.transaction?.error || new Error("Failed to write offline draft state."));
|
| 71 |
+
});
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error("IndexedDB save error:", err);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Pulls the encrypted state block from IndexedDB, decrypts values and restores clean state object.
|
| 79 |
+
*/
|
| 80 |
+
static async getExamState(sessionToken: string): Promise<ExamOfflineState | null> {
|
| 81 |
+
try {
|
| 82 |
+
const db = await this.openDatabase();
|
| 83 |
+
const transaction = db.transaction(this.STORE_NAME, "readonly");
|
| 84 |
+
const store = transaction.objectStore(this.STORE_NAME);
|
| 85 |
+
|
| 86 |
+
return new Promise<ExamOfflineState | null>((resolve, reject) => {
|
| 87 |
+
const request = store.get(sessionToken);
|
| 88 |
+
request.onsuccess = (e: any) => {
|
| 89 |
+
const record = e.target.result;
|
| 90 |
+
if (!record) {
|
| 91 |
+
resolve(null);
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const decAnswers = EncryptionService.decryptObject<Record<string, string>>(record.answersBlob) || {};
|
| 96 |
+
const decFlagged = EncryptionService.decryptObject<string[]>(record.flaggedBlob) || [];
|
| 97 |
+
|
| 98 |
+
resolve({
|
| 99 |
+
sessionToken: record.sessionToken,
|
| 100 |
+
examId: record.examId,
|
| 101 |
+
studentId: record.studentId,
|
| 102 |
+
answers: decAnswers,
|
| 103 |
+
timeLeft: record.timeLeft,
|
| 104 |
+
currentIdx: record.currentIdx,
|
| 105 |
+
flagged: decFlagged,
|
| 106 |
+
lastSaved: record.lastSaved,
|
| 107 |
+
syncPending: record.syncPending
|
| 108 |
+
});
|
| 109 |
+
};
|
| 110 |
+
request.onerror = () => reject(store.transaction?.error || new Error("Failed to retrieve offline draft state."));
|
| 111 |
+
});
|
| 112 |
+
} catch (err) {
|
| 113 |
+
console.error("IndexedDB retrieval error:", err);
|
| 114 |
+
return null;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Wipes any active exam cache entry once submission has successfully been synchronized on the cloud server.
|
| 120 |
+
*/
|
| 121 |
+
static async wipeExamState(sessionToken: string): Promise<void> {
|
| 122 |
+
try {
|
| 123 |
+
const db = await this.openDatabase();
|
| 124 |
+
const transaction = db.transaction(this.STORE_NAME, "readwrite");
|
| 125 |
+
const store = transaction.objectStore(this.STORE_NAME);
|
| 126 |
+
|
| 127 |
+
return new Promise<void>((resolve, reject) => {
|
| 128 |
+
const request = store.delete(sessionToken);
|
| 129 |
+
request.onsuccess = () => resolve();
|
| 130 |
+
request.onerror = () => reject(store.transaction?.error || new Error("Failed to delete offline exam entry."));
|
| 131 |
+
});
|
| 132 |
+
} catch (err) {
|
| 133 |
+
console.error("IndexedDB write/deletion error:", err);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* Returns all exam states flagged with pending sync flags (offline progress waiting to be batched back).
|
| 139 |
+
*/
|
| 140 |
+
static async fetchPendingSyncs(): Promise<ExamOfflineState[]> {
|
| 141 |
+
try {
|
| 142 |
+
const db = await this.openDatabase();
|
| 143 |
+
const transaction = db.transaction(this.STORE_NAME, "readonly");
|
| 144 |
+
const store = transaction.objectStore(this.STORE_NAME);
|
| 145 |
+
|
| 146 |
+
return new Promise<ExamOfflineState[]>((resolve, reject) => {
|
| 147 |
+
const request = store.getAll();
|
| 148 |
+
request.onsuccess = (e: any) => {
|
| 149 |
+
const allRecords = e.target.result || [];
|
| 150 |
+
const matched: ExamOfflineState[] = [];
|
| 151 |
+
|
| 152 |
+
for (const record of allRecords) {
|
| 153 |
+
if (record.syncPending) {
|
| 154 |
+
const decAnswers = EncryptionService.decryptObject<Record<string, string>>(record.answersBlob) || {};
|
| 155 |
+
const decFlagged = EncryptionService.decryptObject<string[]>(record.flaggedBlob) || [];
|
| 156 |
+
matched.push({
|
| 157 |
+
sessionToken: record.sessionToken,
|
| 158 |
+
examId: record.examId,
|
| 159 |
+
studentId: record.studentId,
|
| 160 |
+
answers: decAnswers,
|
| 161 |
+
timeLeft: record.timeLeft,
|
| 162 |
+
currentIdx: record.currentIdx,
|
| 163 |
+
flagged: decFlagged,
|
| 164 |
+
lastSaved: record.lastSaved,
|
| 165 |
+
syncPending: record.syncPending
|
| 166 |
+
});
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
resolve(matched);
|
| 170 |
+
};
|
| 171 |
+
request.onerror = () => reject(store.transaction?.error || new Error("Failed to search pending drafts."));
|
| 172 |
+
});
|
| 173 |
+
} catch (err) {
|
| 174 |
+
console.error("IndexedDB fetch error:", err);
|
| 175 |
+
return [];
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|