Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 46 additions & 156 deletions app/Http/Controllers/Api/CallSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,199 +3,89 @@
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Halaqah\CallSession;
use App\Models\Auth\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Models\Halaqah\CallSession;
use App\Events\CallSessionNotificationEvent;
use App\Events\CallSignalingEvent;
use Illuminate\Support\Str;

class CallSessionController extends Controller
{
/**
* Request a new call session.
*/
public function requestSession(Request $request)
{
$request->validate([
'target_id' => 'required|exists:users,id',
'third_party_id' => 'nullable|exists:users,id',
]);

$initiator = $request->user();
$target = User::findOrFail($request->target_id);

// School Scoping: Must belong to the same school
if ($initiator->school_id !== $target->school_id) {
return response()->json(['error' => 'Users must belong to the same school to initiate a call.'], 403);
}

// Prevent active duplicate sessions
$activeSession = CallSession::where('initiator_id', $initiator->id)
->whereIn('status', ['requested', 'active'])
->first();

if ($activeSession) {
return response()->json([
'error' => 'You already have an active or requested session.',
'session_id' => $activeSession->session_id
], 422);
}

// Max 3 participants logic enforced by schema (initiator, target, third_party)

$session = CallSession::create([
'session_id' => \Illuminate\Support\Str::uuid()->toString(),
'school_id' => $initiator->school_id,
'initiator_id' => $initiator->id,
'target_id' => $target->id,
'status' => 'requested',
'session_id' => Str::uuid()->toString(),
'initiator_id' => $request->user()->id,
'target_id' => $request->target_id,
'third_party_id' => $request->third_party_id,
'status' => 'pending',
]);

// Dispatch WebSocket notification to the target user
broadcast(new \App\Events\CallSessionNotificationEvent($session, 'requested', $target->id));

return response()->json([
'message' => 'Call requested successfully.',
'session' => $session
], 201);
}

/**
* Accept a call session.
*/
public function acceptSession(Request $request, $sessionId)
{
$session = CallSession::where('session_id', $sessionId)->firstOrFail();
$user = $request->user();

if ($session->target_id !== $user->id) {
return response()->json(['error' => 'Unauthorized to accept this call.'], 403);
broadcast(new CallSessionNotificationEvent($session, 'requested', $session->target_id))->toOthers();

if ($session->third_party_id) {
broadcast(new CallSessionNotificationEvent($session, 'requested', $session->third_party_id))->toOthers();
}

if ($session->status !== 'requested') {
return response()->json(['error' => 'Call is no longer in requested state.'], 422);
}

$session->update([
'status' => 'active',
'started_at' => now(),
]);

// Dispatch WebSocket notification to the initiator that the call was accepted
broadcast(new \App\Events\CallSessionNotificationEvent($session, 'accepted', $session->initiator_id));

return response()->json([
'message' => 'Call accepted.',
'session' => $session
]);
return response()->json(['session' => $session]);
}

/**
* Reject a call session.
*/
public function rejectSession(Request $request, $sessionId)
{
$session = CallSession::where('session_id', $sessionId)->firstOrFail();
$user = $request->user();

if ($session->target_id !== $user->id) {
return response()->json(['error' => 'Unauthorized to reject this call.'], 403);
}

if ($session->status !== 'requested') {
return response()->json(['error' => 'Call is no longer in requested state.'], 422);
}

$session->update([
'status' => 'rejected',
'ended_at' => now(),
]);

// Dispatch WebSocket notification to the initiator that the call was rejected
broadcast(new \App\Events\CallSessionNotificationEvent($session, 'rejected', $session->initiator_id));

return response()->json([
'message' => 'Call rejected.',
'session' => $session
]);
}

/**
* Handle WebRTC signaling data (SDP / ICE candidates) and broadcast to peers.
*/
public function signal(Request $request, $sessionId)
public function updateStatus(Request $request, $sessionId)
{
$request->validate([
'signal_data' => 'required|array',
'status' => 'required|in:active,ended,rejected',
]);

$session = CallSession::where('session_id', $sessionId)->firstOrFail();
$user = $request->user();

if (!in_array($user->id, [$session->initiator_id, $session->target_id, $session->third_party_id])) {
return response()->json(['error' => 'Unauthorized.'], 403);

// Only participants can update status
if (!in_array($request->user()->id, [$session->initiator_id, $session->target_id, $session->third_party_id])) {
return response()->json(['error' => 'Unauthorized'], 403);
}

if (!in_array($session->status, ['requested', 'active'])) {
return response()->json(['error' => 'Session is not active.'], 422);
}
$session->update(['status' => $request->status]);

broadcast(new \App\Events\CallSignalingEvent($session, $user->id, $request->signal_data));
// Notify other participants
$participants = array_filter([$session->initiator_id, $session->target_id, $session->third_party_id]);
foreach ($participants as $participantId) {
if ($participantId !== $request->user()->id) {
broadcast(new CallSessionNotificationEvent($session, $request->status, $participantId))->toOthers();
}
}

return response()->json(['message' => 'Signal broadcasted.']);
return response()->json(['session' => $session]);
}

/**
* Broadcast a Mushaf error mark to the student.
*/
public function markMushafError(Request $request, $sessionId)
public function signal(Request $request, $sessionId)
{
$request->validate([
'surah' => 'required|integer',
'ayah' => 'required|integer',
'word_index' => 'required|integer',
'signal_data' => 'required|array',
]);

$session = CallSession::where('session_id', $sessionId)->firstOrFail();
$user = $request->user();

if (!in_array($user->id, [$session->initiator_id, $session->target_id])) {
return response()->json(['error' => 'Unauthorized.'], 403);

if (!in_array($request->user()->id, [$session->initiator_id, $session->target_id, $session->third_party_id])) {
return response()->json(['error' => 'Unauthorized'], 403);
}

if ($session->status !== 'active') {
return response()->json(['error' => 'Session is not active.'], 422);
// Store public keys if they are part of the signal
if (isset($request->signal_data['type']) && $request->signal_data['type'] === 'rsa_pub_key') {
if ($request->user()->id === $session->initiator_id) {
$session->update(['initiator_rsa_pub' => $request->signal_data['key']]);
} elseif ($request->user()->id === $session->target_id) {
$session->update(['target_rsa_pub' => $request->signal_data['key']]);
}
}

broadcast(new \App\Events\MushafErrorMarked($session, $request->only(['surah', 'ayah', 'word_index'])));

return response()->json(['message' => 'Error marked successfully.']);
}
broadcast(new CallSignalingEvent($session, $request->user()->id, $request->signal_data))->toOthers();

/**
* End a call session.
*/
public function endSession(Request $request, $sessionId)
{
$session = CallSession::where('session_id', $sessionId)->firstOrFail();
$user = $request->user();

if (!in_array($user->id, [$session->initiator_id, $session->target_id, $session->third_party_id])) {
return response()->json(['error' => 'Unauthorized.'], 403);
}

$duration = $session->started_at ? now()->diffInSeconds($session->started_at) : 0;

$session->update([
'status' => 'completed',
'ended_at' => now(),
'duration_seconds' => $duration,
]);

// Notify the other participant that the call ended
$notifyTarget = ($user->id === $session->initiator_id) ? $session->target_id : $session->initiator_id;
broadcast(new \App\Events\CallSessionNotificationEvent($session, 'ended', $notifyTarget));

return response()->json([
'message' => 'Call ended.',
'duration' => $duration
]);
return response()->json(['status' => 'Signal sent']);
}
}
41 changes: 9 additions & 32 deletions app/Models/Halaqah/CallSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,35 @@

namespace App\Models\Halaqah;

use App\Models\Auth\User;
use App\Models\School\School;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use App\Models\Auth\User;

class CallSession extends Model
{
use HasFactory;

protected $fillable = [
'session_id',
'school_id',
'initiator_id',
'target_id',
'third_party_id',
'status',
'started_at',
'ended_at',
'duration_seconds',
'metadata',
'initiator_rsa_pub',
'target_rsa_pub',
];

protected $casts = [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'metadata' => 'array',
];

protected static function booted()
{
static::creating(function ($session) {
if (empty($session->session_id)) {
$session->session_id = (string) Str::uuid();
}
});
}

public function school(): BelongsTo
{
return $this->belongsTo(School::class);
}

public function initiator(): BelongsTo
public function initiator()
{
return $this->belongsTo(User::class, 'initiator_id');
}

public function target(): BelongsTo
public function target()
{
return $this->belongsTo(User::class, 'target_id');
}

public function thirdParty(): BelongsTo
public function thirdParty()
{
return $this->belongsTo(User::class, 'third_party_id');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

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

return new class extends Migration
{
public function up(): void
{
Schema::create('call_sessions', function (Blueprint $table) {
$table->id();
$table->uuid('session_id')->unique();
$table->foreignId('initiator_id')->constrained('users')->onDelete('cascade');
$table->foreignId('target_id')->constrained('users')->onDelete('cascade');
$table->foreignId('third_party_id')->nullable()->constrained('users')->onDelete('set null');
$table->enum('status', ['pending', 'active', 'ended', 'rejected'])->default('pending');
$table->text('initiator_rsa_pub')->nullable();
$table->text('target_rsa_pub')->nullable();
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('call_sessions');
}
};
7 changes: 7 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

use App\Http\Controllers\Api\CallSessionController;
Route::middleware('auth:sanctum')->group(function () {
Route::post('/call-sessions', [CallSessionController::class, 'requestSession']);
Route::put('/call-sessions/{sessionId}/status', [CallSessionController::class, 'updateStatus']);
Route::post('/call-sessions/{sessionId}/signal', [CallSessionController::class, 'signal']);
});
Loading