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:
v0andcetinmustafa83 committed 2026-07-31 13:53:06 +00:00
1 parent 8b33280c20
commit 0c9a46c163
11 files changed
+1800

No files matched your search

+163
View File
@@ -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>
);
}
+145
View File
@@ -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>
);
}
+208
View File
@@ -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>
);
}
+120
View File
@@ -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;
}