Skip to main content

Complete Documentation & Implementation

Table of Contents

  1. System Overview
  2. Installation & Setup
  3. Database Schema
  4. Laravel Backend Implementation
  5. Frontend Implementation
  6. Moodle Plugin
  7. API Documentation
  8. Deployment Guide
  9. Security & Privacy
  10. Performance Optimization

System Overview

Architecture Diagram

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Moodle LMS    │    │   Laravel API   │    │  Vue.js Client  │
│                 │    │                 │    │                 │
│ ┌─────────────┐ │    │ ┌─────────────┐ │    │ ┌─────────────┐ │
│ │   Plugin    │◄┼────┤ │ Auth Service│ │    │ │ Proctoring  │ │
│ │             │ │    │ │             │ │    │ │ Interface   │ │
│ └─────────────┘ │    │ └─────────────┘ │    │ └─────────────┘ │
│                 │    │                 │    │                 │
│ ┌─────────────┐ │    │ ┌─────────────┐ │    │ ┌─────────────┐ │
│ │    Quiz     │ │    │ │ Recording   │ │    │ │ Monitoring  │ │
│ │   Access    │ │    │ │ Service     │ │    │ │ Dashboard   │ │
│ │             │ │    │ │             │ │    │ │             │ │
│ └─────────────┘ │    │ └─────────────┘ │    │ └─────────────┘ │
└─────────────────┘    └─────────────────┘    └─────────────────┘

Technology Stack

  • Backend: Laravel 10.x with PHP 8.1+
  • Frontend: Vue.js 3 with Composition API
  • Database: MySQL 8.0+
  • Real-time: Laravel Echo + Pusher/Redis
  • Storage: AWS S3 or local storage
  • Queue: Redis with Laravel Horizon
  • Caching: Redis

Installation & Setup

Prerequisites

# Required software
- PHP 8.1+
- Node.js 18+
- MySQL 8.0+
- Redis 6.0+
- Composer
- npm/yarn

Laravel Backend Setup

# Create new Laravel project
composer create-project laravel/laravel proctoring-system
cd proctoring-system

# Install required packages
composer require laravel/sanctum
composer require pusher/pusher-php-server
composer require league/flysystem-aws-s3-v3
composer require intervention/image
composer require laravel/horizon

# Install frontend dependencies
npm install vue@next @vitejs/plugin-vue
npm install @tensorflow/tfjs
npm install face-api.js
npm install recordrtc
npm install pusher-js laravel-echo

Environment Configuration

# .env file
APP_NAME="Proctoring System"
APP_ENV=production
APP_KEY=base64:your-app-key
APP_DEBUG=false
APP_URL=https://your-domain.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=proctoring_system
DB_USERNAME=your_username
DB_PASSWORD=your_password

BROADCAST_DRIVER=pusher
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis

PUSHER_APP_ID=your_app_id
PUSHER_APP_KEY=your_app_key
PUSHER_APP_SECRET=your_app_secret
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1

AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name

MOODLE_URL=https://your-moodle-site.com
MOODLE_TOKEN=your_moodle_webservice_token

Database Schema

Migration Files

<?php
// database/migrations/create_proctoring_sessions_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('proctoring_sessions', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('student_id');
            $table->unsignedBigInteger('quiz_id');
            $table->unsignedBigInteger('moodle_user_id');
            $table->string('session_token', 255)->unique();
            $table->timestamp('start_time');
            $table->timestamp('end_time')->nullable();
            $table->enum('status', ['active', 'completed', 'terminated', 'paused'])->default('active');
            $table->json('device_info');
            $table->json('recordings')->nullable();
            $table->string('ip_address', 45);
            $table->text('user_agent');
            $table->integer('violation_count')->default(0);
            $table->decimal('overall_score', 5, 2)->default(100.00);
            $table->timestamps();
            
            $table->index(['student_id', 'quiz_id']);
            $table->index('session_token');
            $table->index('status');
        });
    }

    public function down()
    {
        Schema::dropIfExists('proctoring_sessions');
    }
};
<?php
// database/migrations/create_violations_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('violations', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('session_id');
            $table->enum('type', [
                'no_face', 'multiple_faces', 'face_not_clear', 'tab_switch', 
                'window_blur', 'phone_detected', 'book_detected', 'person_detected',
                'screen_share_ended', 'fullscreen_exit', 'right_click', 'copy_paste',
                'browser_extension', 'virtual_machine'
            ]);
            $table->timestamp('timestamp');
            $table->decimal('confidence_score', 5, 2)->default(0.00);
            $table->string('screenshot_path')->nullable();
            $table->integer('video_bookmark_time'); // seconds from start
            $table->json('detection_data')->nullable();
            $table->enum('severity', ['low', 'medium', 'high', 'critical'])->default('medium');
            $table->boolean('reviewed')->default(false);
            $table->text('proctor_notes')->nullable();
            $table->timestamps();
            
            $table->foreign('session_id')->references('id')->on('proctoring_sessions')->onDelete('cascade');
            $table->index(['session_id', 'type']);
            $table->index('timestamp');
            $table->index('severity');
        });
    }

    public function down()
    {
        Schema::dropIfExists('violations');
    }
};
<?php
// database/migrations/create_recording_chunks_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('recording_chunks', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('session_id');
            $table->enum('type', ['screen', 'webcam', 'audio']);
            $table->string('file_path');
            $table->integer('chunk_number');
            $table->integer('start_time'); // seconds from session start
            $table->integer('duration'); // seconds
            $table->unsignedBigInteger('file_size');
            $table->string('mime_type');
            $table->enum('status', ['uploading', 'completed', 'failed'])->default('uploading');
            $table->timestamps();
            
            $table->foreign('session_id')->references('id')->on('proctoring_sessions')->onDelete('cascade');
            $table->index(['session_id', 'type', 'chunk_number']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('recording_chunks');
    }
};

Laravel Backend Implementation

Models

<?php
// app/Models/ProctoringSession.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ProctoringSession extends Model
{
    use HasFactory;

    protected $fillable = [
        'student_id',
        'quiz_id',
        'moodle_user_id',
        'session_token',
        'start_time',
        'end_time',
        'status',
        'device_info',
        'recordings',
        'ip_address',
        'user_agent',
        'violation_count',
        'overall_score'
    ];

    protected $casts = [
        'device_info' => 'array',
        'recordings' => 'array',
        'start_time' => 'datetime',
        'end_time' => 'datetime',
        'overall_score' => 'decimal:2'
    ];

    public function violations(): HasMany
    {
        return $this->hasMany(Violation::class, 'session_id');
    }

    public function recordingChunks(): HasMany
    {
        return $this->hasMany(RecordingChunk::class, 'session_id');
    }

    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

    public function scopeCompleted($query)
    {
        return $query->where('status', 'completed');
    }

    public function addViolation(string $type, float $confidence, array $data = [])
    {
        $this->violations()->create([
            'type' => $type,
            'timestamp' => now(),
            'confidence_score' => $confidence,
            'video_bookmark_time' => now()->diffInSeconds($this->start_time),
            'detection_data' => $data,
            'severity' => $this->calculateSeverity($type, $confidence)
        ]);

        $this->increment('violation_count');
        $this->updateOverallScore();
    }

    private function calculateSeverity(string $type, float $confidence): string
    {
        $criticalViolations = ['screen_share_ended', 'tab_switch', 'virtual_machine'];
        $highViolations = ['no_face', 'multiple_faces', 'phone_detected'];
        
        if (in_array($type, $criticalViolations)) {
            return 'critical';
        } elseif (in_array($type, $highViolations) && $confidence > 0.8) {
            return 'high';
        } elseif ($confidence > 0.6) {
            return 'medium';
        }
        
        return 'low';
    }

    private function updateOverallScore()
    {
        $penalties = [
            'critical' => 20,
            'high' => 10,
            'medium' => 5,
            'low' => 2
        ];

        $totalPenalty = $this->violations()
            ->get()
            ->sum(function ($violation) use ($penalties) {
                return $penalties[$violation->severity] ?? 0;
            });

        $this->overall_score = max(0, 100 - $totalPenalty);
        $this->save();
    }
}
<?php
// app/Models/Violation.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Violation extends Model
{
    use HasFactory;

    protected $fillable = [
        'session_id',
        'type',
        'timestamp',
        'confidence_score',
        'screenshot_path',
        'video_bookmark_time',
        'detection_data',
        'severity',
        'reviewed',
        'proctor_notes'
    ];

    protected $casts = [
        'timestamp' => 'datetime',
        'detection_data' => 'array',
        'confidence_score' => 'decimal:2',
        'reviewed' => 'boolean'
    ];

    public function session(): BelongsTo
    {
        return $this->belongsTo(ProctoringSession::class, 'session_id');
    }

    public function scopeUnreviewed($query)
    {
        return $query->where('reviewed', false);
    }

    public function scopeBySeverity($query, string $severity)
    {
        return $query->where('severity', $severity);
    }
}

Controllers

<?php
// app/Http/Controllers/ProctoringController.php

namespace App\Http\Controllers;

use App\Models\ProctoringSession;
use App\Models\Violation;
use App\Services\MoodleService;
use App\Services\ProctoringService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class ProctoringController extends Controller
{
    protected ProctoringService $proctoringService;
    protected MoodleService $moodleService;

    public function __construct(ProctoringService $proctoringService, MoodleService $moodleService)
    {
        $this->proctoringService = $proctoringService;
        $this->moodleService = $moodleService;
    }

    public function initializeSession(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'student_id' => 'required|integer',
            'quiz_id' => 'required|integer',
            'moodle_user_id' => 'required|integer'
        ]);

        try {
            // Verify student access to quiz via Moodle
            $quizAccess = $this->moodleService->verifyQuizAccess(
                $validated['moodle_user_id'],
                $validated['quiz_id']
            );

            if (!$quizAccess) {
                return response()->json(['error' => 'Quiz access denied'], 403);
            }

            // Create proctoring session
            $session = ProctoringSession::create([
                'student_id' => $validated['student_id'],
                'quiz_id' => $validated['quiz_id'],
                'moodle_user_id' => $validated['moodle_user_id'],
                'session_token' => Str::random(64),
                'start_time' => now(),
                'device_info' => $this->extractDeviceInfo($request),
                'ip_address' => $request->ip(),
                'user_agent' => $request->userAgent(),
                'status' => 'active'
            ]);

            // Generate Moodle quiz URL with token
            $quizUrl = $this->moodleService->generateQuizUrl(
                $validated['quiz_id'],
                $session->session_token
            );

            return response()->json([
                'session_id' => $session->id,
                'session_token' => $session->session_token,
                'quiz_url' => $quizUrl,
                'websocket_channel' => "proctoring.{$session->id}",
                'requirements' => $this->getSystemRequirements()
            ]);

        } catch (\Exception $e) {
            Log::error('Failed to initialize proctoring session', [
                'error' => $e->getMessage(),
                'request' => $validated
            ]);

            return response()->json(['error' => 'Failed to initialize session'], 500);
        }
    }

    public function validateToken(string $token): JsonResponse
    {
        $session = ProctoringSession::where('session_token', $token)
            ->where('status', 'active')
            ->first();

        if (!$session) {
            return response()->json(['valid' => false], 404);
        }

        // Check if session is still within time limits
        if ($session->start_time->diffInHours(now()) > 4) {
            $session->update(['status' => 'terminated']);
            return response()->json(['valid' => false], 403);
        }

        return response()->json([
            'valid' => true,
            'session_id' => $session->id,
            'student_id' => $session->student_id,
            'quiz_id' => $session->quiz_id
        ]);
    }

    public function recordViolation(Request $request, int $sessionId): JsonResponse
    {
        $validated = $request->validate([
            'type' => 'required|string',
            'confidence' => 'required|numeric|between:0,1',
            'screenshot' => 'nullable|string', // base64 encoded
            'detection_data' => 'nullable|array'
        ]);

        try {
            $session = ProctoringSession::findOrFail($sessionId);
            
            // Save screenshot if provided
            $screenshotPath = null;
            if ($validated['screenshot']) {
                $screenshotPath = $this->saveScreenshot(
                    $validated['screenshot'],
                    $sessionId
                );
            }

            // Create violation record
            $violation = $session->violations()->create([
                'type' => $validated['type'],
                'timestamp' => now(),
                'confidence_score' => $validated['confidence'],
                'screenshot_path' => $screenshotPath,
                'video_bookmark_time' => now()->diffInSeconds($session->start_time),
                'detection_data' => $validated['detection_data'] ?? [],
                'severity' => $this->calculateSeverity($validated['type'], $validated['confidence'])
            ]);

            // Update session violation count and score
            $session->increment('violation_count');
            $this->updateSessionScore($session);

            // Broadcast violation to proctors
            broadcast(new \App\Events\ViolationDetected($violation))->toOthers();

            // Check if session should be terminated
            if ($this->shouldTerminateSession($session)) {
                $session->update(['status' => 'terminated']);
                broadcast(new \App\Events\SessionTerminated($session))->toOthers();
            }

            return response()->json([
                'violation_id' => $violation->id,
                'session_status' => $session->status,
                'violation_count' => $session->violation_count,
                'overall_score' => $session->overall_score
            ]);

        } catch (\Exception $e) {
            Log::error('Failed to record violation', [
                'error' => $e->getMessage(),
                'session_id' => $sessionId,
                'violation_data' => $validated
            ]);

            return response()->json(['error' => 'Failed to record violation'], 500);
        }
    }

    public function uploadRecordingChunk(Request $request, int $sessionId): JsonResponse
    {
        $validated = $request->validate([
            'type' => 'required|in:screen,webcam,audio',
            'chunk_number' => 'required|integer',
            'file' => 'required|file|max:10240', // 10MB max
            'start_time' => 'required|integer',
            'duration' => 'required|integer'
        ]);

        try {
            $session = ProctoringSession::findOrFail($sessionId);
            
            // Generate file path
            $fileName = sprintf(
                '%s_%s_chunk_%d.webm',
                $sessionId,
                $validated['type'],
                $validated['chunk_number']
            );
            
            $filePath = "recordings/{$sessionId}/{$fileName}";
            
            // Store file
            $path = Storage::disk('s3')->put($filePath, $request->file('file'));
            
            // Create chunk record
            $chunk = $session->recordingChunks()->create([
                'type' => $validated['type'],
                'file_path' => $path,
                'chunk_number' => $validated['chunk_number'],
                'start_time' => $validated['start_time'],
                'duration' => $validated['duration'],
                'file_size' => $request->file('file')->getSize(),
                'mime_type' => $request->file('file')->getMimeType(),
                'status' => 'completed'
            ]);

            return response()->json([
                'chunk_id' => $chunk->id,
                'status' => 'uploaded'
            ]);

        } catch (\Exception $e) {
            Log::error('Failed to upload recording chunk', [
                'error' => $e->getMessage(),
                'session_id' => $sessionId,
                'chunk_data' => $validated
            ]);

            return response()->json(['error' => 'Failed to upload chunk'], 500);
        }
    }

    public function endSession(Request $request, int $sessionId): JsonResponse
    {
        try {
            $session = ProctoringSession::findOrFail($sessionId);
            
            $session->update([
                'status' => 'completed',
                'end_time' => now()
            ]);

            // Queue video processing job
            \App\Jobs\ProcessSessionRecording::dispatch($session);

            // Notify Moodle of session completion
            $this->moodleService->notifySessionCompletion($session);

            return response()->json([
                'status' => 'completed',
                'session_duration' => $session->start_time->diffInMinutes($session->end_time),
                'total_violations' => $session->violation_count,
                'overall_score' => $session->overall_score
            ]);

        } catch (\Exception $e) {
            Log::error('Failed to end session', [
                'error' => $e->getMessage(),
                'session_id' => $sessionId
            ]);

            return response()->json(['error' => 'Failed to end session'], 500);
        }
    }

    private function extractDeviceInfo(Request $request): array
    {
        $userAgent = $request->userAgent();
        
        return [
            'user_agent' => $userAgent,
            'ip_address' => $request->ip(),
            'screen_resolution' => $request->input('screen_resolution'),
            'timezone' => $request->input('timezone'),
            'language' => $request->input('language'),
            'platform' => $this->detectPlatform($userAgent),
            'browser' => $this->detectBrowser($userAgent)
        ];
    }

    private function detectPlatform(string $userAgent): string
    {
        if (preg_match('/Windows/i', $userAgent)) return 'Windows';
        if (preg_match('/Mac/i', $userAgent)) return 'macOS';
        if (preg_match('/Linux/i', $userAgent)) return 'Linux';
        if (preg_match('/Android/i', $userAgent)) return 'Android';
        if (preg_match('/iPhone|iPad/i', $userAgent)) return 'iOS';
        
        return 'Unknown';
    }

    private function detectBrowser(string $userAgent): string
    {
        if (preg_match('/Chrome/i', $userAgent)) return 'Chrome';
        if (preg_match('/Firefox/i', $userAgent)) return 'Firefox';
        if (preg_match('/Safari/i', $userAgent)) return 'Safari';
        if (preg_match('/Edge/i', $userAgent)) return 'Edge';
        
        return 'Unknown';
    }

    private function saveScreenshot(string $base64Data, int $sessionId): string
    {
        $imageData = base64_decode(preg_replace('/^data:image\/\w+;base64,/', '', $base64Data));
        $fileName = sprintf('screenshot_%s_%s.jpg', $sessionId, now()->timestamp);
        $filePath = "screenshots/{$sessionId}/{$fileName}";
        
        Storage::disk('s3')->put($filePath, $imageData);
        
        return $filePath;
    }

    private function calculateSeverity(string $type, float $confidence): string
    {
        $criticalViolations = ['screen_share_ended', 'tab_switch', 'virtual_machine'];
        $highViolations = ['no_face', 'multiple_faces', 'phone_detected'];
        
        if (in_array($type, $criticalViolations)) {
            return 'critical';
        } elseif (in_array($type, $highViolations) && $confidence > 0.8) {
            return 'high';
        } elseif ($confidence > 0.6) {
            return 'medium';
        }
        
        return 'low';
    }

    private function updateSessionScore(ProctoringSession $session): void
    {
        $penalties = [
            'critical' => 20,
            'high' => 10,
            'medium' => 5,
            'low' => 2
        ];

        $totalPenalty = $session->violations()
            ->get()
            ->sum(function ($violation) use ($penalties) {
                return $penalties[$violation->severity] ?? 0;
            });

        $session->update([
            'overall_score' => max(0, 100 - $totalPenalty)
        ]);
    }

    private function shouldTerminateSession(ProctoringSession $session): bool
    {
        $criticalViolations = $session->violations()
            ->where('severity', 'critical')
            ->count();
            
        $highViolations = $session->violations()
            ->where('severity', 'high')
            ->count();

        return $criticalViolations >= 3 || $highViolations >= 5 || $session->overall_score < 30;
    }

    private function getSystemRequirements(): array
    {
        return [
            'webcam' => true,
            'microphone' => true,
            'screen_sharing' => true,
            'browsers' => ['chrome', 'firefox', 'edge'],
            'min_bandwidth' => '1mbps',
            'fullscreen_required' => true
        ];
    }
}

Services

<?php
// app/Services/MoodleService.php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class MoodleService
{
    protected string $baseUrl;
    protected string $token;

    public function __construct()
    {
        $this->baseUrl = config('services.moodle.url');
        $this->token = config('services.moodle.token');
    }

    public function verifyQuizAccess(int $userId, int $quizId): bool
    {
        try {
            $response = Http::get("{$this->baseUrl}/webservice/rest/server.php", [
                'wstoken' => $this->token,
                'wsfunction' => 'mod_quiz_get_quiz_access_information',
                'moodlewsrestformat' => 'json',
                'quizid' => $quizId,
                'userid' => $userId
            ]);

            if ($response->successful()) {
                $data = $response->json();
                return $data['canattempt'] ?? false;
            }

            return false;

        } catch (\Exception $e) {
            Log::error('Moodle quiz access verification failed', [
                'error' => $e->getMessage(),
                'user_id' => $userId,
                'quiz_id' => $quizId
            ]);

            return false;
        }
    }

    public function generateQuizUrl(int $quizId, string $proctoringToken): string
    {
        return "{$this->baseUrl}/mod/quiz/view.php?id={$quizId}&proctoring_token={$proctoringToken}";
    }

    public function notifySessionCompletion(object $session): void
    {
        try {
            Http::post("{$this->baseUrl}/webservice/rest/server.php", [
                'wstoken' => $this->token,
                'wsfunction' => 'local_proctoring_session_completed',
                'moodlewsrestformat' => 'json',
                'sessiondata' => json_encode([
                    'session_id' => $session->id,
                    'user_id' => $session->moodle_user_id,
                    'quiz_id' => $session->quiz_id,
                    'violation_count' => $session->violation_count,
                    'overall_score' => $session->overall_score,
                    'duration' => $session->start_time->diffInMinutes($session->end_time)
                ])
            ]);

        } catch (\Exception $e) {
            Log::error('Failed to notify Moodle of session completion', [
                'error' => $e->getMessage(),
                'session_id' => $session->id
            ]);
        }
    }

    public function getUserInfo(int $userId): array
    {
        try {
            $response = Http::get("{$this->baseUrl}/webservice/rest/server.php", [
                'wstoken' => $this->token,
                'wsfunction' => 'core_user_get_users_by_field',
                'moodlewsrestformat' => 'json',
                'field' => 'id',
                'values[0]' => $userId
            ]);

            if ($response->successful()) {
                $data = $response->json();
                return $data[0] ?? [];
            }

            return [];

        } catch (\Exception $e) {
            Log::error('Failed to get Moodle user info', [
                'error' => $e->getMessage(),
                'user_id' => $userId
            ]);

            return [];
        }
    }
}

Events

<?php
// app/Events/ViolationDetected.php

namespace App\Events;

use App\Models\Violation;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ViolationDetected implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Violation $violation;

    public function __construct(Violation $violation)
    {
        $this->violation = $violation;
    }

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('proctoring.' . $this->violation->session_id),
            new PrivateChannel('proctor.dashboard')
        ];
    }

    public function broadcastWith(): array
    {
        return [
            'violation' => [
                'id' => $this->violation->id,
                'session_id' => $this->violation->session_id,
                'type' => $this->violation->type,
                'timestamp' => $this->violation->timestamp,
                'confidence_score' => $this->violation->confidence_score,
                'severity' => $this->violation->severity,
                'video_bookmark_time' => $this->violation->video_bookmark_time,
                'detection_data' => $this->violation->detection_data
            ],
            'session' => [
                'id' => $this->violation->session->id,
                'student_id' => $this->violation->session->student_id,
                'quiz_id' => $this->violation->session->quiz_id,
                'violation_count' => $this->violation->session->violation_count,
                'overall_score' => $this->violation->session->overall_score
            ]
        ];
    }
}
<?php
// app/Events/SessionTerminated.php

namespace App\Events;

use App\Models\ProctoringSession;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class SessionTerminated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public ProctoringSession $session;

    public function __construct(ProctoringSession $session)
    {
        $this->session = $session;
    }

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('proctoring.' . $this->session->id),
            new PrivateChannel('proctor.dashboard')
        ];
    }

    public function broadcastWith(): array
    {
        return [
            'session' => [
                'id' => $this->session->id,
                'student_id' => $this->session->student_id,
                'quiz_id' => $this->session->quiz_id,
                'status' => $this->session->status,
                'violation_count' => $this->session->violation_count,
                'overall_score' => $this->session->overall_score,
                'termination_reason' => 'Excessive violations detected'
            ]
        ];
    }
}

Jobs

<?php
// app/Jobs/ProcessSessionRecording.php

namespace App\Jobs;

use App\Models\ProctoringSession;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

class ProcessSessionRecording implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected ProctoringSession $session;

    public function __construct(ProctoringSession $session)
    {
        $this->session = $session;
    }

    public function handle(): void
    {
        try {
            Log::info('Starting recording processing for session', ['session_id' => $this->session->id]);

            // Get all recording chunks
            $screenChunks = $this->session->recordingChunks()
                ->where('type', 'screen')
                ->orderBy('chunk_number')
                ->get();

            $webcamChunks = $this->session->recordingChunks()
                ->where('type', 'webcam')
                ->orderBy('chunk_number')
                ->get();

            // Process screen recording
            if ($screenChunks->isNotEmpty()) {
                $screenVideoPath = $this->mergeVideoChunks($screenChunks, 'screen');
                $this->addViolationBookmarks($screenVideoPath, 'screen');
            }

            // Process webcam recording
            if ($webcamChunks->isNotEmpty()) {
                $webcamVideoPath = $this->mergeVideoChunks($webcamChunks, 'webcam');
                $this->addViolationBookmarks($webcamVideoPath, 'webcam');
            }

            // Update session with final recording paths
            $this->session->update([
                'recordings' => [
                    'screen' => $screenVideoPath ?? null,
                    'webcam' => $webcamVideoPath ?? null,
                    'processed_at' => now()
                ]
            ]);

            Log::info('Recording processing completed', ['session_id' => $this->session->id]);

        } catch (\Exception $e) {
            Log::error('Recording processing failed', [
                'session_id' => $this->session->id,
                'error' => $e->getMessage()
            ]);

            $this->fail($e);
        }
    }

    private function mergeVideoChunks($chunks, string $type): string
    {
        $outputPath = "recordings/{$this->session->id}/merged_{$type}.mp4";
        
        // Create file list for FFmpeg
        $fileList = $chunks->map(function ($chunk) {
            return "file '" . Storage::disk('s3')->url($chunk->file_path) . "'";
        })->implode("\n");

        $listPath = storage_path("app/temp/filelist_{$this->session->id}_{$type}.txt");
        file_put_contents($listPath, $fileList);

        // Run FFmpeg to merge chunks
        $command = "ffmpeg -f concat -safe 0 -i {$listPath} -c copy " . storage_path("app/temp/merged_{$type}.mp4");
        exec($command);

        // Upload merged video to S3
        $mergedContent = file_get_contents(storage_path("app/temp/merged_{$type}.mp4"));
        Storage::disk('s3')->put($outputPath, $mergedContent);

        // Clean up temp files
        unlink($listPath);
        unlink(storage_path("app/temp/merged_{$type}.mp4"));

        return $outputPath;
    }

    private function addViolationBookmarks(string $videoPath, string $type): void
    {
        $violations = $this->session->violations()
            ->orderBy('timestamp')
            ->get();

        if ($violations->isEmpty()) {
            return;
        }

        // Create chapter file for video bookmarks
        $chapters = $violations->map(function ($violation) {
            return sprintf(
                "[CHAPTER]\nTIMEBASE=1/1\nSTART=%d\nEND=%d\ntitle=%s - %s (%.2f%%)",
                $violation->video_bookmark_time,
                $violation->video_bookmark_time + 5,
                strtoupper($violation->type),
                $violation->severity,
                $violation->confidence_score * 100
            );
        })->implode("\n\n");

        $chaptersPath = storage_path("app/temp/chapters_{$this->session->id}_{$type}.txt");
        file_put_contents($chaptersPath, $chapters);

        // Add chapters to video
        $inputPath = Storage::disk('s3')->url($videoPath);
        $outputPath = "recordings/{$this->session->id}/final_{$type}.mp4";
        
        $command = "ffmpeg -i {$inputPath} -i {$chaptersPath} -map 0 -map_metadata 1 -c copy " . 
                   storage_path("app/temp/final_{$type}.mp4");
        exec($command);

        // Upload final video
        $finalContent = file_get_contents(storage_path("app/temp/final_{$type}.mp4"));
        Storage::disk('s3')->put($outputPath, $finalContent);

        // Clean up
        unlink($chaptersPath);
        unlink(storage_path("app/temp/final_{$type}.mp4"));

        // Update session recordings
        $recordings = $this->session->recordings ?? [];
        $recordings[$type] = $outputPath;
        $this->session->update(['recordings' => $recordings]);
    }
}

Proctor Dashboard Controller

<?php
// app/Http/Controllers/ProctorDashboardController.php

namespace App\Http\Controllers;

use App\Models\ProctoringSession;
use App\Models\Violation;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;

class ProctorDashboardController extends Controller
{
    public function getActiveSessions(): JsonResponse
    {
        $sessions = ProctoringSession::with(['violations' => function ($query) {
                $query->orderBy('timestamp', 'desc')->limit(5);
            }])
            ->where('status', 'active')
            ->select(['id', 'student_id', 'quiz_id', 'start_time', 'violation_count', 'overall_score'])
            ->get();

        return response()->json($sessions);
    }

    public function getSessionDetails(int $sessionId): JsonResponse
    {
        $session = ProctoringSession::with(['violations' => function ($query) {
                $query->orderBy('timestamp', 'desc');
            }])
            ->findOrFail($sessionId);

        return response()->json([
            'session' => $session,
            'violations' => $session->violations,
            'recording_urls' => $this->getRecordingUrls($session),
            'statistics' => $this->getSessionStatistics($session)
        ]);
    }

    public function getViolationDetails(int $violationId): JsonResponse
    {
        $violation = Violation::with('session')->findOrFail($violationId);

        return response()->json([
            'violation' => $violation,
            'screenshot_url' => $violation->screenshot_path ? 
                Storage::disk('s3')->temporaryUrl($violation->screenshot_path, now()->addHour()) : null,
            'video_url' => $this->getVideoUrlWithTimestamp($violation),
            'context' => $this->getViolationContext($violation)
        ]);
    }

    public function updateViolationStatus(Request $request, int $violationId): JsonResponse
    {
        $validated = $request->validate([
            'reviewed' => 'required|boolean',
            'proctor_notes' => 'nullable|string'
        ]);

        $violation = Violation::findOrFail($violationId);
        $violation->update($validated);

        return response()->json(['status' => 'updated']);
    }

    public function terminateSession(Request $request, int $sessionId): JsonResponse
    {
        $validated = $request->validate([
            'reason' => 'required|string'
        ]);

        $session = ProctoringSession::findOrFail($sessionId);
        $session->update([
            'status' => 'terminated',
            'end_time' => now()
        ]);

        // Log termination reason
        $session->violations()->create([
            'type' => 'manual_termination',
            'timestamp' => now(),
            'confidence_score' => 1.0,
            'video_bookmark_time' => now()->diffInSeconds($session->start_time),
            'detection_data' => ['reason' => $validated['reason']],
            'severity' => 'critical'
        ]);

        // Broadcast termination
        broadcast(new \App\Events\SessionTerminated($session));

        return response()->json(['status' => 'terminated']);
    }

    public function getSessionRecording(int $sessionId, string $type): JsonResponse
    {
        $session = ProctoringSession::findOrFail($sessionId);
        $recordings = $session->recordings ?? [];

        if (!isset($recordings[$type])) {
            return response()->json(['error' => 'Recording not found'], 404);
        }

        $videoUrl = Storage::disk('s3')->temporaryUrl($recordings[$type], now()->addHours(2));
        
        return response()->json([
            'video_url' => $videoUrl,
            'violations' => $this->getViolationsForVideo($session),
            'duration' => $session->end_time ? 
                $session->start_time->diffInSeconds($session->end_time) : 
                $session->start_time->diffInSeconds(now())
        ]);
    }

    private function getRecordingUrls(ProctoringSession $session): array
    {
        $recordings = $session->recordings ?? [];
        $urls = [];

        foreach ($recordings as $type => $path) {
            if ($path && Storage::disk('s3')->exists($path)) {
                $urls[$type] = Storage::disk('s3')->temporaryUrl($path, now()->addHours(2));
            }
        }

        return $urls;
    }

    private function getSessionStatistics(ProctoringSession $session): array
    {
        $violations = $session->violations;

        return [
            'total_violations' => $violations->count(),
            'violations_by_type' => $violations->groupBy('type')->map->count(),
            'violations_by_severity' => $violations->groupBy('severity')->map->count(),
            'duration_minutes' => $session->end_time ? 
                $session->start_time->diffInMinutes($session->end_time) : 
                $session->start_time->diffInMinutes(now()),
            'avg_confidence' => $violations->avg('confidence_score'),
            'peak_violation_time' => $this->getPeakViolationTime($violations)
        ];
    }

    private function getVideoUrlWithTimestamp(Violation $violation): ?string
    {
        $recordings = $violation->session->recordings ?? [];
        
        if (!isset($recordings['screen'])) {
            return null;
        }

        $baseUrl = Storage::disk('s3')->temporaryUrl($recordings['screen'], now()->addHours(2));
        
        return $baseUrl . '#t=' . $violation->video_bookmark_time;
    }

    private function getViolationContext(Violation $violation): array
    {
        // Get violations within 30 seconds before and after
        $contextViolations = $violation->session->violations()
            ->whereBetween('video_bookmark_time', [
                max(0, $violation->video_bookmark_time - 30),
                $violation->video_bookmark_time + 30
            ])
            ->where('id', '!=', $violation->id)
            ->orderBy('video_bookmark_time')
            ->get();

        return [
            'before' => $contextViolations->where('video_bookmark_time', '<', $violation->video_bookmark_time)->values(),
            'after' => $contextViolations->where('video_bookmark_time', '>', $violation->video_bookmark_time)->values()
        ];
    }

    private function getViolationsForVideo(ProctoringSession $session): array
    {
        return $session->violations()
            ->orderBy('video_bookmark_time')
            ->get()
            ->map(function ($violation) {
                return [
                    'id' => $violation->id,
                    'type' => $violation->type,
                    'time' => $violation->video_bookmark_time,
                    'severity' => $violation->severity,
                    'confidence' => $violation->confidence_score,
                    'reviewed' => $violation->reviewed
                ];
            })
            ->toArray();
    }

    private function getPeakViolationTime($violations): ?int
    {
        if ($violations->isEmpty()) {
            return null;
        }

        // Group violations by 5-minute intervals
        $intervals = $violations->groupBy(function ($violation) {
            return intval($violation->video_bookmark_time / 300) * 300;
        });

        // Find interval with most violations
        $peakInterval = $intervals->sortByDesc(function ($group) {
            return $group->count();
        })->first();

        return $peakInterval ? $peakInterval->first()->video_bookmark_time : null;
    }
}

Frontend Implementation

Vue.js Components

<!-- resources/js/components/ProctoringInterface.vue -->
<template>
  <div class="proctoring-interface">
    <!-- Header -->
    <div class="header">
      <div class="session-info">
        <h3>Quiz Session Active</h3>
        <div class="session-stats">
          <span class="timer">{{ formatTime(sessionDuration) }}</span>
          <span class="violations">Violations: {{ violationCount }}</span>
          <span class="score" :class="scoreClass">Score: {{ overallScore }}%</span>
        </div>
      </div>
      <div class="controls">
        <button @click="endSession" class="btn btn-danger">End Session</button>
      </div>
    </div>

    <!-- Main Content -->
    <div class="content">
      <!-- Quiz Frame -->
      <div class="quiz-container">
        <iframe 
          ref="quizFrame"
          :src="quizUrl"
          class="quiz-frame"
          @load="onQuizLoaded"
        ></iframe>
      </div>

      <!-- Monitoring Panel -->
      <div class="monitoring-panel">
        <!-- Webcam Feed -->
        <div class="webcam-section">
          <h4>Webcam Monitor</h4>
          <video 
            ref="webcamVideo"
            class="webcam-feed"
            autoplay
            muted
          ></video>
          <div class="webcam-status" :class="webcamStatusClass">
            {{ webcamStatus }}
          </div>
        </div>

        <!-- System Status -->
        <div class="system-status">
          <h4>System Status</h4>
          <div class="status-items">
            <div class="status-item" :class="screenShareStatus">
              <span class="icon">📺</span>
              <span>Screen Share: {{ screenShareActive ? 'Active' : 'Inactive' }}</span>
            </div>
            <div class="status-item" :class="connectionStatus">
              <span class="icon">🌐</span>
              <span>Connection: {{ connectionStrength }}</span>
            </div>
            <div class="status-item" :class="browserStatus">
              <span class="icon">🌏</span>
              <span>Browser: {{ browserCompatible ? 'Compatible' : 'Incompatible' }}</span>
            </div>
          </div>
        </div>

        <!-- Recent Violations -->
        <div class="violations-section">
          <h4>Recent Violations</h4>
          <div class="violations-list">
            <div 
              v-for="violation in recentViolations" 
              :key="violation.id"
              class="violation-item"
              :class="'severity-' + violation.severity"
            >
              <div class="violation-type">{{ formatViolationType(violation.type) }}</div>
              <div class="violation-time">{{ formatTime(violation.video_bookmark_time) }}</div>
              <div class="violation-confidence">{{ Math.round(violation.confidence_score * 100) }}%</div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- Violation Alert Modal -->
    <div v-if="showViolationAlert" class="violation-alert-modal">
      <div class="modal-content">
        <div class="alert-header">
          <h3>⚠️ Violation Detected</h3>
        </div>
        <div class="alert-body">
          <p>{{ currentViolation.message }}</p>
          <p class="alert-instruction">{{ currentViolation.instruction }}</p>
        </div>
        <div class="alert-footer">
          <button @click="acknowledgeViolation" class="btn btn-primary">
            I Understand
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useProctoring } from '@/composables/useProctoring'
import { useViolationDetection } from '@/composables/useViolationDetection'
import { useRecording } from '@/composables/useRecording'

export default {
  name: 'ProctoringInterface',
  props: {
    sessionId: {
      type: Number,
      required: true
    },
    sessionToken: {
      type: String,
      required: true
    },
    quizUrl: {
      type: String,
      required: true
    }
  },
  setup(props) {
    const quizFrame = ref(null)
    const webcamVideo = ref(null)
    
    const {
      sessionDuration,
      violationCount,
      overallScore,
      sessionStatus,
      endSession
    } = useProctoring(props.sessionId, props.sessionToken)

    const {
      violations,
      currentViolation,
      showViolationAlert,
      acknowledgeViolation,
      startDetection,
      stopDetection
    } = useViolationDetection(props.sessionId, webcamVideo)

    const {
      screenShareActive,
      webcamStatus,
      connectionStrength,
      browserCompatible,
      startRecording,
      stopRecording
    } = useRecording(props.sessionId)

    const recentViolations = computed(() => 
      violations.value.slice(-5).reverse()
    )

    const scoreClass = computed(() => {
      if (overallScore.value >= 80) return 'score-good'
      if (overallScore.value >= 60) return 'score-warning'
      return 'score-danger'
    })

    const webcamStatusClass = computed(() => {
      switch (webcamStatus.value) {
        case 'Face detected': return 'status-good'
        case 'No face detected': return 'status-danger'
        case 'Multiple faces': return 'status-warning'
        default: return 'status-unknown'
      }
    })

    const screenShareStatus = computed(() => 
      screenShareActive.value ? 'status-good' : 'status-danger'
    )

    const connectionStatus = computed(() => {
      if (connectionStrength.value === 'Strong') return 'status-good'
      if (connectionStrength.value === 'Weak') return 'status-warning'
      return 'status-danger'
    })

    const browserStatus = computed(() => 
      browserCompatible.value ? 'status-good' : 'status-danger'
    )

    const formatTime = (seconds) => {
      const mins = Math.floor(seconds / 60)
      const secs = seconds % 60
      return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
    }

    const formatViolationType = (type) => {
      return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
    }

    const onQuizLoaded = () => {
      // Quiz iframe loaded, start monitoring
      startDetection()
      startRecording()
    }

    onMounted(() => {
      // Initialize webcam
      navigator.mediaDevices.getUserMedia({ video: true, audio: true })
        .then(stream => {
          webcamVideo.value.srcObject = stream
        })
        .catch(err => {
          console.error('Failed to access webcam:', err)
        })
    })

    onUnmounted(() => {
      stopDetection()
      stopRecording()
    })

    return {
      quizFrame,
      webcamVideo,
      sessionDuration,
      violationCount,
      overallScore,
      recentViolations,
      currentViolation,
      showViolationAlert,
      screenShareActive,
      webcamStatus,
      connectionStrength,
      browserCompatible,
      scoreClass,
      webcamStatusClass,
      screenShareStatus,
      connectionStatus,
      browserStatus,
      formatTime,
      formatViolationType,
      acknowledgeViolation,
      endSession,
      onQuizLoaded
    }
  }
}
</script>

<style scoped>
.proctoring-interface {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #f5f5f5;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background: white;
  border-bottom: 1px solid #ddd;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.session-info h3 {
  margin: 0 0 0.5rem 0;
  color: #333;
}

.session-stats {
  display: flex;
  gap: 1rem;
}

.session-stats span {
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-size: 0.9rem;
  font-weight: 500;
}

.timer {
  background: #e3f2fd;
  color: #1976d2;
}

.violations {
  background: #fff3e0;
  color: #f57c00;
}

.score {
  font-weight: 600;
}

.score-good {
  background: #e8f5e8;
  color: #2e7d32;
}

.score-warning {
  background: #fff3e0;
  color: #f57c00;
}

.score-danger {
  background: #ffebee;
  color: #c62828;
}

.content {
  display: flex;
  flex: 1;
  gap: 1rem;
  padding: 1rem;
}

.quiz-container {
  flex: 1;
  background: white;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.quiz-frame {
  width: 100%;
  height: 100%;
  border: none;
}

.monitoring-panel {
  width: 350px;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.webcam-section,
.system-status,
.violations-section {
  background: white;
  padding: 1rem;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.webcam-section h4,
.system-status h4,
.violations-section h4 {
  margin: 0 0 1rem 0;
  color: #333;
  font-size: 1rem;
}

.webcam-feed {
  width: 100%;
  height: 200px;
  border-radius: 4px;
  object-fit: cover;
  background: #000;
}

.webcam-status {
  text-align: center;
  padding: 0.5rem;
  margin-top: 0.5rem;
  border-radius: 4px;
  font-size: 0.9rem;
  font-weight: 500;
}

.status-good {
  background: #e8f5e8;
  color: #2e7d32;
}

.status-warning {
  background: #fff3e0;
  color: #f57c00;
}

.status-danger {
  background: #ffebee;
  color: #c62828;
}

.status-unknown {
  background: #f5f5f5;
  color: #666;
}

.status-items {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.status-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem;
  border-radius: 4px;
  font-size: 0.9rem;
}

.violations-list {
  max-height: 200px;
  overflow-y: auto;
}

.violation-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem;
  margin-bottom: 0.5rem;
  border-radius: 4px;
  font-size: 0.85rem;
}

.severity-low {
  background: #f3e5f5;
  color: #7b1fa2;
}

.severity-medium {
  background: #fff3e0;
  color: #f57c00;
}

.severity-high {
  background: #ffebee;
  color: #c62828;
}

.severity-critical {
  background: #ffcdd2;
  color: #b71c1c;
  font-weight: 600;
}

.violation-alert-modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0,0,0,0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}

.alert-header h3 {
  margin: 0 0 1rem 0;
  color: #d32f2f;
}

.alert-body p {
  margin: 0 0