Benjahmin commited on
Commit
8a790fb
·
1 Parent(s): fbb8b5c

feat(offline): implement offline resilience for exams

Browse files

Add 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 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 sessionData: any = null;
 
63
  if (sessionToken) {
64
  try {
65
- const sRes = await fetch(`/api/sessions/status/${sessionToken}`);
66
- if (sRes.ok) {
67
- sessionData = await sRes.json();
 
 
 
 
 
 
 
 
68
  }
69
- } catch (sessionErr) {
70
- console.warn("Failed to pull active backup session state:", sessionErr);
71
  }
72
  }
73
 
74
- if (sessionData && sessionData.examId === examId) {
75
- // Restore saved answer index
76
- setAnswers(sessionData.answers || {});
77
-
78
- // Restore question navigation index
79
- if (sessionData.currentQuestionId) {
80
- const indexMatched = qs.findIndex(q => q.id === sessionData.currentQuestionId);
81
- if (indexMatched !== -1) {
82
- setCurrentIdx(indexMatched);
 
83
  }
84
  }
85
 
86
- // Restore exact timer from the server
87
- const remainingTimerValue = sessionData.tempTimer !== undefined ? sessionData.tempTimer : (examData.duration * 60);
88
- setTimeLeft(Math.max(0, remainingTimerValue));
89
- setExamStarted(true);
90
- } else {
91
- // Standard Fallback Attempts Recovery
92
- const existingAttempt = await DataService.getAttempt(attId);
93
- if (existingAttempt) {
94
- setAnswers(existingAttempt.answers || {});
95
- if (existingAttempt.startedAt) {
96
- const startTimes = (existingAttempt.startedAt.seconds || Date.now()/1000);
97
- const elapsed = Math.floor(Date.now()/1000 - startTimes);
98
- const remaining = (examData.duration * 60) - elapsed;
99
- setTimeLeft(Math.max(0, remaining));
100
- if (remaining > 0) setExamStarted(true);
101
- }
102
  } else {
103
- setTimeLeft(examData.duration * 60);
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sessionToken = localStorage.getItem('relay_last_session_token');
123
-
124
- // Level-1: Immediate local storage persistence
125
- localStorage.setItem(`relay_answers_${examId}`, JSON.stringify(answers));
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- if (attemptId) {
128
- const timeSpent = Math.max(0, Math.round((exam?.duration || 0) * 60 - timeLeft));
129
- DataService.updateAttemptProgress(attemptId, answers, timeSpent);
130
- }
131
 
132
- if (!sessionToken) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- // Level-2: Background sync to the secure database every 5 seconds
135
- const intervalId = setInterval(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- }).catch(err => console.warn("Failed background autosave sync:", err));
147
- }, 5000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- return () => clearInterval(intervalId);
150
- }, [answers, attemptId, examStarted, exam?.duration, timeLeft, examId, questions, currentIdx]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- await DataService.submitAttempt(attemptId, exam.id, answers, timeSpent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  navigate(`/student/results/${attemptId}`);
197
- } catch (error) {
198
  console.error(error);
199
- alert("Submission failed. Check your connection.");
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(attemptId: string, examId: string, answers: any, timeSpent: number): Promise<any> {
 
 
 
 
 
 
 
351
  const response = await this.authFetch('/api/exams/submit', {
352
  method: 'POST',
353
  headers: { 'Content-Type': 'application/json' },
354
- body: JSON.stringify({ attemptId, examId, answers, timeSpent })
 
 
 
 
 
 
 
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
+ }