From 5f42c029be59fb6bc0bad6a37791ddca61662114 Mon Sep 17 00:00:00 2001 From: shafeea360 <3.19195194e+08+shafeea360@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:11:48 +0000 Subject: [PATCH] feat(api): implement WebRTC call session management and signaling via Reverb --- .../Controllers/Api/CallSessionController.php | 202 ++++-------------- app/Models/Halaqah/CallSession.php | 41 +--- ...8_27_000000_create_call_sessions_table.php | 28 +++ routes/api.php | 7 + 4 files changed, 90 insertions(+), 188 deletions(-) create mode 100644 database/migrations/2026_08_27_000000_create_call_sessions_table.php create mode 100644 routes/api.php diff --git a/app/Http/Controllers/Api/CallSessionController.php b/app/Http/Controllers/Api/CallSessionController.php index 1ddb2ee..0600aa0 100644 --- a/app/Http/Controllers/Api/CallSessionController.php +++ b/app/Http/Controllers/Api/CallSessionController.php @@ -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']); } } diff --git a/app/Models/Halaqah/CallSession.php b/app/Models/Halaqah/CallSession.php index d7b6df3..aec6667 100644 --- a/app/Models/Halaqah/CallSession.php +++ b/app/Models/Halaqah/CallSession.php @@ -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'); } diff --git a/database/migrations/2026_08_27_000000_create_call_sessions_table.php b/database/migrations/2026_08_27_000000_create_call_sessions_table.php new file mode 100644 index 0000000..045f6c7 --- /dev/null +++ b/database/migrations/2026_08_27_000000_create_call_sessions_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..dd5fb3d --- /dev/null +++ b/routes/api.php @@ -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']); +});