feat: add new API Implementation Guide for Modules B, F, H
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
11 files changed
+1800
No files matched your search
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useApi } from '@/lib/hooks/useApi';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, Eye, Clock, Check, X } from 'lucide-react';
|
||||
import { formatTimeRemaining } from '@/lib/exam-utils';
|
||||
|
||||
interface ExamSessionRow {
|
||||
id: string;
|
||||
studentName: string;
|
||||
assessmentTitle: string;
|
||||
status: string;
|
||||
timeRemaining: number;
|
||||
suspiciousEvents: number;
|
||||
riskScore: number;
|
||||
}
|
||||
|
||||
export default function ExamProctoringPage() {
|
||||
const [sessions, setSessions] = useState<ExamSessionRow[]>([]);
|
||||
const { data: examSessions, isLoading } = useApi('/api/v1/exams/active-sessions');
|
||||
|
||||
useEffect(() => {
|
||||
if (examSessions) {
|
||||
const processed = examSessions.map((session: any) => ({
|
||||
id: session.id,
|
||||
studentName: session.student?.firstName + ' ' + session.student?.lastName,
|
||||
assessmentTitle: session.assessment?.title,
|
||||
status: session.status,
|
||||
timeRemaining: Math.max(0, Math.ceil((new Date(session.endTime).getTime() - Date.now()) / 60000)),
|
||||
suspiciousEvents: session.events?.filter((e: any) => e.severity !== 'info').length || 0,
|
||||
riskScore: 0,
|
||||
}));
|
||||
setSessions(processed);
|
||||
}
|
||||
}, [examSessions]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'studentName',
|
||||
label: 'Student',
|
||||
sortable: true,
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
key: 'assessmentTitle',
|
||||
label: 'Assessment',
|
||||
sortable: true,
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (value: string) => (
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
value === 'in_progress' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'timeRemaining',
|
||||
label: 'Time',
|
||||
render: (value: number) => (
|
||||
<span className={value <= 5 ? 'font-bold text-red-600' : ''}>
|
||||
{formatTimeRemaining(value)}
|
||||
</span>
|
||||
),
|
||||
width: '12%',
|
||||
},
|
||||
{
|
||||
key: 'suspiciousEvents',
|
||||
label: 'Alerts',
|
||||
render: (value: number) => (
|
||||
value > 0 ? (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{value}
|
||||
</div>
|
||||
) : (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
)
|
||||
),
|
||||
width: '12%',
|
||||
},
|
||||
{
|
||||
key: 'id',
|
||||
label: 'Actions',
|
||||
render: (id: string) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.location.href = `/admin/exam-proctoring/${id}`}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Monitor
|
||||
</Button>
|
||||
),
|
||||
width: '11%',
|
||||
},
|
||||
];
|
||||
|
||||
const activeCount = sessions.filter((s) => s.status === 'in_progress').length;
|
||||
const alertCount = sessions.reduce((sum, s) => sum + s.suspiciousEvents, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Exam Proctoring</h1>
|
||||
<p className="text-gray-600 mt-1">Monitor active exam sessions and suspicious activity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm font-medium">Active Sessions</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{activeCount}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm font-medium">Alerts</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{alertCount}</p>
|
||||
</div>
|
||||
<AlertCircle className={`w-8 h-8 ${alertCount > 0 ? 'text-red-500' : 'text-green-500'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm font-medium">Total Sessions</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{sessions.length}</p>
|
||||
</div>
|
||||
<Eye className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions Table */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={sessions}
|
||||
isLoading={isLoading}
|
||||
searchPlaceholder="Search sessions..."
|
||||
searchableColumns={['studentName', 'assessmentTitle']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { ExamSession, ExamEvent } from '@prisma/client';
|
||||
import {
|
||||
getExamTimeRemaining,
|
||||
formatTimeRemaining,
|
||||
shouldShowTimeWarning,
|
||||
calculateSecurityMetrics,
|
||||
DEFAULT_EXAM_CONFIG,
|
||||
} from '@/lib/exam-utils';
|
||||
import { AlertCircle, Clock, Eye, AlertTriangle } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
interface ExamSessionMonitorProps {
|
||||
examSession: ExamSession & { events: ExamEvent[] };
|
||||
durationMinutes: number;
|
||||
onTimeWarning?: (minutesRemaining: number) => void;
|
||||
onSuspiciousActivity?: (event: ExamEvent) => void;
|
||||
}
|
||||
|
||||
export function ExamSessionMonitor({
|
||||
examSession,
|
||||
durationMinutes,
|
||||
onTimeWarning,
|
||||
onSuspiciousActivity,
|
||||
}: ExamSessionMonitorProps) {
|
||||
const [timeRemaining, setTimeRemaining] = useState(
|
||||
getExamTimeRemaining(examSession.startTime, durationMinutes)
|
||||
);
|
||||
const [securityMetrics, setSecurityMetrics] = useState(
|
||||
calculateSecurityMetrics(examSession.events)
|
||||
);
|
||||
const [isWarningVisible, setIsWarningVisible] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update time remaining every second
|
||||
useEffect(() => {
|
||||
timerRef.current = setInterval(() => {
|
||||
const remaining = getExamTimeRemaining(examSession.startTime, durationMinutes);
|
||||
setTimeRemaining(remaining);
|
||||
|
||||
// Check for time warnings
|
||||
if (shouldShowTimeWarning(remaining, DEFAULT_EXAM_CONFIG.timeWarnings)) {
|
||||
setIsWarningVisible(true);
|
||||
onTimeWarning?.(remaining);
|
||||
setTimeout(() => setIsWarningVisible(false), 3000);
|
||||
}
|
||||
|
||||
// Auto-submit if time's up
|
||||
if (remaining === 0) {
|
||||
handleTimeExpired();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [examSession.startTime, durationMinutes, onTimeWarning]);
|
||||
|
||||
// Update security metrics when events change
|
||||
useEffect(() => {
|
||||
const metrics = calculateSecurityMetrics(examSession.events);
|
||||
setSecurityMetrics(metrics);
|
||||
}, [examSession.events]);
|
||||
|
||||
const handleTimeExpired = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
// Trigger form submission via event
|
||||
window.dispatchEvent(new CustomEvent('exam-time-expired'));
|
||||
}, []);
|
||||
|
||||
const getRiskColor = (score: number): string => {
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 40) return 'text-orange-600';
|
||||
return 'text-yellow-600';
|
||||
};
|
||||
|
||||
const getTimeColor = (minutes: number): string => {
|
||||
if (minutes <= 1) return 'text-red-600 font-bold';
|
||||
if (minutes <= 5) return 'text-orange-600 font-semibold';
|
||||
return 'text-gray-700';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Time Remaining */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-gray-500" />
|
||||
<span className={`text-lg font-mono ${getTimeColor(timeRemaining)}`}>
|
||||
{formatTimeRemaining(timeRemaining)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Security Status */}
|
||||
{examSession.lockdownEnabled && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Eye className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-600">Exam Mode Active</span>
|
||||
</div>
|
||||
|
||||
{securityMetrics.riskScore > 0 && (
|
||||
<div className={`flex items-center gap-1 text-sm ${getRiskColor(securityMetrics.riskScore)}`}>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Risk: {securityMetrics.riskScore}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Count */}
|
||||
<div className="text-sm text-gray-600">
|
||||
Events: {examSession.events.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Warning Alert */}
|
||||
{isWarningVisible && timeRemaining <= 5 && (
|
||||
<Alert className="mt-2 border-orange-300 bg-orange-50">
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-800">
|
||||
{timeRemaining === 0
|
||||
? 'Time has expired. Your exam will be submitted automatically.'
|
||||
: `${timeRemaining} minute${timeRemaining !== 1 ? 's' : ''} remaining. Make sure to submit your answers.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Suspicious Activity Alert */}
|
||||
{securityMetrics.suspiciousEvents > 0 && (
|
||||
<Alert className="mt-2 border-red-300 bg-red-50">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<AlertDescription className="text-red-800">
|
||||
Unusual activity detected ({securityMetrics.suspiciousEvents} event
|
||||
{securityMetrics.suspiciousEvents !== 1 ? 's' : ''}). This will be reviewed by your instructor.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useApi } from '@/lib/hooks/useApi';
|
||||
import { Bell, X, Check, Archive, AlertCircle, Info, CheckCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
isRead: boolean;
|
||||
isDismissed: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function NotificationHub() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const { data: hubData, mutate } = useApi('/api/v1/notifications/hub');
|
||||
|
||||
useEffect(() => {
|
||||
if (hubData) {
|
||||
setNotifications(hubData.filter((n: any) => !n.isDismissed));
|
||||
}
|
||||
}, [hubData]);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.isRead).length;
|
||||
const criticalCount = notifications.filter((n) => n.priority === 'critical' && !n.isRead).length;
|
||||
|
||||
const handleMarkAsRead = async (notificationId: string) => {
|
||||
await fetch(`/api/v1/notifications/${notificationId}/read`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
mutate();
|
||||
};
|
||||
|
||||
const handleDismiss = async (notificationId: string) => {
|
||||
await fetch(`/api/v1/notifications/${notificationId}/dismiss`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
mutate();
|
||||
};
|
||||
|
||||
const handleArchive = async (notificationId: string) => {
|
||||
await fetch(`/api/v1/notifications/${notificationId}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
mutate();
|
||||
};
|
||||
|
||||
const getIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'assessment':
|
||||
return <CheckCircle className="w-4 h-4 text-blue-500" />;
|
||||
case 'behavior':
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'communication':
|
||||
return <Bell className="w-4 h-4 text-purple-500" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'border-l-4 border-l-red-500 bg-red-50';
|
||||
case 'high':
|
||||
return 'border-l-4 border-l-orange-500 bg-orange-50';
|
||||
case 'normal':
|
||||
return 'border-l-4 border-l-blue-500 bg-blue-50';
|
||||
default:
|
||||
return 'border-l-4 border-l-gray-300 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Notification Bell Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className={`absolute top-0 right-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold text-white ${
|
||||
criticalCount > 0 ? 'bg-red-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-50 max-h-96 overflow-y-auto">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500">No new notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 hover:bg-gray-50 transition ${getPriorityColor(notification.priority)}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{getIcon(notification.category)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className={`text-sm font-medium ${notification.isRead ? 'text-gray-600' : 'text-gray-900 font-bold'}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.isRead && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
|
||||
{notification.actionUrl && (
|
||||
<a
|
||||
href={notification.actionUrl}
|
||||
className="inline-block text-xs text-blue-600 hover:text-blue-800 font-medium mt-2"
|
||||
>
|
||||
{notification.actionLabel || 'View'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!notification.isRead && (
|
||||
<button
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
className="p-1 text-gray-400 hover:text-blue-600"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleArchive(notification.id)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Archive"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDismiss(notification.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Link */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="p-3 border-t border-gray-200 text-center">
|
||||
<a
|
||||
href="/notifications"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ExamSession, ExamEvent } from '@prisma/client';
|
||||
|
||||
export interface ExamMonitoringConfig {
|
||||
lockdownMode: boolean;
|
||||
cameraEnabled: boolean;
|
||||
keyloggerDetection: boolean;
|
||||
tabSwitchWarning: boolean;
|
||||
copyPasteBlock: boolean;
|
||||
timeWarnings: number[]; // minutes before end
|
||||
}
|
||||
|
||||
export const DEFAULT_EXAM_CONFIG: ExamMonitoringConfig = {
|
||||
lockdownMode: true,
|
||||
cameraEnabled: false,
|
||||
keyloggerDetection: false,
|
||||
tabSwitchWarning: true,
|
||||
copyPasteBlock: true,
|
||||
timeWarnings: [15, 5, 1], // warn at 15, 5, 1 minute remaining
|
||||
};
|
||||
|
||||
export function getExamTimeRemaining(startTime: Date, durationMinutes: number): number {
|
||||
const now = new Date();
|
||||
const endTime = new Date(startTime.getTime() + durationMinutes * 60000);
|
||||
const remainingMs = endTime.getTime() - now.getTime();
|
||||
return Math.max(0, Math.ceil(remainingMs / 60000)); // return minutes
|
||||
}
|
||||
|
||||
export function formatTimeRemaining(minutes: number): string {
|
||||
if (minutes < 0) return 'Time expired';
|
||||
if (minutes === 0) return 'Less than 1 minute';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export function shouldShowTimeWarning(
|
||||
minutesRemaining: number,
|
||||
warnings: number[]
|
||||
): boolean {
|
||||
return warnings.some(
|
||||
(warningThreshold) =>
|
||||
minutesRemaining === warningThreshold ||
|
||||
(minutesRemaining < warningThreshold && minutesRemaining === Math.ceil(minutesRemaining))
|
||||
);
|
||||
}
|
||||
|
||||
export function detectSuspiciousActivity(event: ExamEvent): boolean {
|
||||
const suspiciousTypes = [
|
||||
'tab_switch',
|
||||
'copy_paste',
|
||||
'window_blur',
|
||||
'camera_detected_none',
|
||||
'suspicious_movement',
|
||||
];
|
||||
return suspiciousTypes.includes(event.eventType) && event.severity !== 'info';
|
||||
}
|
||||
|
||||
export function generateExamToken(examSessionId: string, studentId: string): string {
|
||||
const timestamp = Date.now();
|
||||
const hash = btoa(`${examSessionId}:${studentId}:${timestamp}`);
|
||||
return `exam_${hash}`;
|
||||
}
|
||||
|
||||
export function validateExamToken(token: string, examSessionId: string, studentId: string): boolean {
|
||||
if (!token.startsWith('exam_')) return false;
|
||||
try {
|
||||
const expected = `exam_${btoa(`${examSessionId}:${studentId}`)}`;
|
||||
return token.includes(btoa(`${examSessionId}:${studentId}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isExamActiveForStudent(exam: ExamSession): boolean {
|
||||
if (exam.status !== 'in_progress') return false;
|
||||
const now = new Date();
|
||||
return exam.startTime <= now && (!exam.endTime || now <= exam.endTime);
|
||||
}
|
||||
|
||||
export function getExamProgressPercentage(
|
||||
totalQuestions: number,
|
||||
answeredQuestions: number
|
||||
): number {
|
||||
return Math.round((answeredQuestions / totalQuestions) * 100);
|
||||
}
|
||||
|
||||
export interface ExamSecurityMetrics {
|
||||
suspiciousEvents: number;
|
||||
tabSwitches: number;
|
||||
copyCounts: number;
|
||||
focusLosses: number;
|
||||
riskScore: number; // 0-100
|
||||
}
|
||||
|
||||
export function calculateSecurityMetrics(events: ExamEvent[]): ExamSecurityMetrics {
|
||||
const metrics: ExamSecurityMetrics = {
|
||||
suspiciousEvents: 0,
|
||||
tabSwitches: 0,
|
||||
copyCounts: 0,
|
||||
focusLosses: 0,
|
||||
riskScore: 0,
|
||||
};
|
||||
|
||||
events.forEach((event) => {
|
||||
if (event.severity !== 'info') metrics.suspiciousEvents++;
|
||||
if (event.eventType === 'tab_switch') metrics.tabSwitches++;
|
||||
if (event.eventType === 'copy_paste') metrics.copyCounts++;
|
||||
if (event.eventType === 'window_blur') metrics.focusLosses++;
|
||||
});
|
||||
|
||||
metrics.riskScore = Math.min(
|
||||
100,
|
||||
metrics.tabSwitches * 5 + metrics.copyCounts * 10 + metrics.focusLosses * 3
|
||||
);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
Reference in new issue
Block a user