From e03d6ce250e2cfe2cd6b47b480e32ada64114904 Mon Sep 17 00:00:00 2001 From: subhajit Date: Wed, 19 Aug 2026 20:26:02 +0530 Subject: [PATCH] implement client calling --- app/Http/Controllers/AdminController.php | 13 +- app/Http/Controllers/ClientCallController.php | 691 +++++++++ app/Mail/ClientCallMomMail.php | 59 + app/Mail/ClientMeetingInviteMail.php | 60 + app/Models/Client.php | 92 ++ app/Models/ClientCallNote.php | 74 + app/Models/ClientMeeting.php | 92 ++ app/Models/User.php | 48 + app/Services/AiClientCallService.php | 689 +++++++++ ...ignored_violations_to_interviews_table.php | 29 + ...2026_08_19_100000_create_clients_table.php | 38 + ...19_100001_create_client_meetings_table.php | 43 + ..._100002_create_client_call_notes_table.php | 41 + ...s_and_todos_to_client_call_notes_table.php | 29 + database/seeders/ClientCallSeeder.php | 277 ++++ database/seeders/DatabaseSeeder.php | 5 + public/js/client-call.js | 469 +++++++ resources/js/client-call.js | 459 ++++++ resources/views/admin/dashboard.blade.php | 198 ++- .../views/client_calls/guest_join.blade.php | 157 +++ resources/views/client_calls/index.blade.php | 937 +++++++++++++ resources/views/client_calls/logs.blade.php | 1010 +++++++++++++ resources/views/client_calls/room.blade.php | 575 ++++++++ resources/views/client_calls/show.blade.php | 1244 +++++++++++++++++ resources/views/dashboard.blade.php | 6 + resources/views/emails/call_mom.blade.php | 113 ++ .../views/emails/meeting_invite.blade.php | 84 ++ routes/web.php | 22 + tests/Feature/ClientCallFeatureTest.php | 461 ++++++ 29 files changed, 8012 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/ClientCallController.php create mode 100644 app/Mail/ClientCallMomMail.php create mode 100644 app/Mail/ClientMeetingInviteMail.php create mode 100644 app/Models/Client.php create mode 100644 app/Models/ClientCallNote.php create mode 100644 app/Models/ClientMeeting.php create mode 100644 app/Services/AiClientCallService.php create mode 100644 database/migrations/2026_08_12_160000_add_sos_and_ignored_violations_to_interviews_table.php create mode 100644 database/migrations/2026_08_19_100000_create_clients_table.php create mode 100644 database/migrations/2026_08_19_100001_create_client_meetings_table.php create mode 100644 database/migrations/2026_08_19_100002_create_client_call_notes_table.php create mode 100644 database/migrations/2026_08_19_110000_add_emotion_details_and_todos_to_client_call_notes_table.php create mode 100644 database/seeders/ClientCallSeeder.php create mode 100644 public/js/client-call.js create mode 100644 resources/js/client-call.js create mode 100644 resources/views/client_calls/guest_join.blade.php create mode 100644 resources/views/client_calls/index.blade.php create mode 100644 resources/views/client_calls/logs.blade.php create mode 100644 resources/views/client_calls/room.blade.php create mode 100644 resources/views/client_calls/show.blade.php create mode 100644 resources/views/emails/call_mom.blade.php create mode 100644 resources/views/emails/meeting_invite.blade.php create mode 100644 tests/Feature/ClientCallFeatureTest.php diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 2937cc8..0fe618d 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers; -use App\Models\User; use App\Models\App; +use App\Models\Client; +use App\Models\ClientCallNote; +use App\Models\ClientMeeting; use App\Models\Role; +use App\Models\User; use App\Models\UserAppOverride; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -30,6 +33,9 @@ public function index() // Fetch all overrides $overrides = UserAppOverride::with(['user', 'app'])->orderBy('created_at', 'desc')->get(); + // Fetch all client projects & meetings + $clients = Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes'])->orderBy('created_at', 'desc')->get(); + // Calculate statistics $stats = [ 'total_users' => User::count(), @@ -37,11 +43,14 @@ public function index() 'blocked_count' => User::where('is_blocked', true)->count(), 'apps_count' => App::count(), 'roles_count' => Role::count(), + 'clients_count' => Client::count(), + 'client_meetings_count' => ClientMeeting::count(), + 'angry_clients_count' => Client::where('latest_sentiment', 'angry')->count(), ]; $defaultRoleName = \App\Models\Setting::get('default_role', 'Developer'); - return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'stats', 'defaultRoleName')); + return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'clients', 'stats', 'defaultRoleName')); } /** diff --git a/app/Http/Controllers/ClientCallController.php b/app/Http/Controllers/ClientCallController.php new file mode 100644 index 0000000..b23d573 --- /dev/null +++ b/app/Http/Controllers/ClientCallController.php @@ -0,0 +1,691 @@ +aiService = $aiService; + } + + /** + * List all client calls and projects. + */ + public function index() + { + $user = Auth::user(); + + if (!$user->canAccessClientCalls()) { + return redirect()->route('dashboard')->with('error', 'Client Calls management is reserved for PMs, Team Leads, Directors, and Administrators.'); + } + + $clients = $user->getAccessibleClients(); + $allUsers = User::orderBy('name', 'asc')->get(); + + // Filter managers / leads for dropdowns + $managersAndLeads = $allUsers->filter(function ($u) { + return $u->canAccessClientCalls(); + }); + + // Quick stats + $totalClients = $clients->count(); + $totalMeetings = ClientMeeting::whereIn('client_id', $clients->pluck('id'))->count(); + $angryClientsCount = $clients->where('latest_sentiment', 'angry')->count(); + + return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'angryClientsCount')); + } + + /** + * Store a new Client & Project profile. + */ + public function store(Request $request) + { + $user = Auth::user(); + + if (!$user->canAccessClientCalls()) { + return redirect()->route('dashboard')->with('error', 'Unauthorized action.'); + } + + $request->validate([ + 'client_name' => 'required|string|max:255', + 'client_email' => 'required|email|max:255', + 'client_phone' => 'nullable|string|max:50', + 'company_name' => 'nullable|string|max:255', + 'project_name' => 'required|string|max:255', + 'project_details' => 'nullable|string', + 'responsible_user_id' => 'required|exists:users,id', + 'assigned_team_members' => 'nullable|array', + ]); + + $client = Client::create([ + 'client_name' => $request->client_name, + 'client_email' => strtolower(trim($request->client_email)), + 'client_phone' => $request->client_phone, + 'company_name' => $request->company_name, + 'project_name' => $request->project_name, + 'project_details' => $request->project_details, + 'responsible_user_id' => $request->responsible_user_id, + 'created_by' => $user->id, + 'assigned_team_members' => $request->assigned_team_members ?? [], + 'status' => 'active', + 'latest_sentiment' => 'neutral', + ]); + + return redirect()->route('client-calls.index')->with('success', "Client '{$client->client_name}' & Project '{$client->project_name}' created successfully!"); + } + + /** + * Show Client Call Hub (opens in new tab). + */ + public function show($id) + { + $user = Auth::user(); + + if (!$user->canAccessClientCalls()) { + return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); + } + + $client = Client::with(['responsibleUser', 'creator', 'meetings.host', 'meetings.latestNote', 'callNotes.creator'])->findOrFail($id); + $allUsers = User::orderBy('name', 'asc')->get(); + + $canViewAngry = $client->canViewAngryAnalysis($user); + + return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry')); + } + + /** + * Schedule a future meeting. + */ + public function scheduleMeeting(Request $request, $clientId) + { + $user = Auth::user(); + $client = Client::findOrFail($clientId); + + $request->validate([ + 'title' => 'required|string|max:255', + 'agenda' => 'nullable|string', + 'scheduled_at' => 'required|date', + 'duration_minutes' => 'required|integer|min:5|max:480', + 'team_attendees' => 'nullable|array', + 'client_emails' => 'nullable|string', // comma separated emails + ]); + + // Parse client emails + $clientEmails = []; + if (!empty($request->client_emails)) { + $rawEmails = explode(',', $request->client_emails); + foreach ($rawEmails as $email) { + $trimmed = strtolower(trim($email)); + if (filter_var($trimmed, FILTER_VALIDATE_EMAIL)) { + $clientEmails[] = $trimmed; + } + } + } + // Always include primary client email + if (!in_array(strtolower(trim($client->client_email)), $clientEmails)) { + $clientEmails[] = strtolower(trim($client->client_email)); + } + + $meetingCode = 'call-' . Str::lower(Str::random(9)); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => $request->title, + 'agenda' => $request->agenda, + 'meeting_code' => $meetingCode, + 'meeting_type' => 'scheduled', + 'scheduled_at' => Carbon::parse($request->scheduled_at), + 'duration_minutes' => (int)$request->duration_minutes, + 'host_user_id' => $user->id, + 'team_attendees' => $request->team_attendees ?? [], + 'client_emails' => $clientEmails, + 'status' => 'scheduled', + ]); + + // Send meeting invitation email to clients & internal team attendees + $this->sendMeetingInvitations($meeting); + + return redirect()->route('client-calls.show', $client->id)->with('success', "Meeting '{$meeting->title}' scheduled! Invitations with join link have been emailed to attendees."); + } + + /** + * Create an instant meeting room immediately. + */ + public function createInstantMeeting(Request $request, $clientId) + { + $user = Auth::user(); + $client = Client::findOrFail($clientId); + + $title = $request->input('title') ?: ("Instant Sync - " . $client->project_name); + $meetingCode = 'quick-' . Str::lower(Str::random(9)); + + $clientEmails = [strtolower(trim($client->client_email))]; + if ($request->filled('client_emails')) { + $raw = explode(',', $request->client_emails); + foreach ($raw as $e) { + $t = strtolower(trim($e)); + if (filter_var($t, FILTER_VALIDATE_EMAIL) && !in_array($t, $clientEmails)) { + $clientEmails[] = $t; + } + } + } + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => $title, + 'agenda' => $request->input('agenda', 'Instant ad-hoc collaboration call'), + 'meeting_code' => $meetingCode, + 'meeting_type' => 'instant', + 'scheduled_at' => now(), + 'duration_minutes' => 45, + 'host_user_id' => $user->id, + 'team_attendees' => $request->team_attendees ?? [$user->id], + 'client_emails' => $clientEmails, + 'status' => 'in_progress', + 'started_at' => now(), + ]); + + // If requested to send invite email immediately + if ($request->boolean('send_email_now', true)) { + $this->sendMeetingInvitations($meeting); + } + + if ($request->ajax() || $request->wantsJson()) { + return response()->json([ + 'success' => true, + 'meeting_id' => $meeting->id, + 'meeting_code' => $meeting->meeting_code, + 'join_url' => $meeting->join_url, + ]); + } + + return redirect()->route('client-calls.room', $meeting->meeting_code); + } + + /** + * Live Video Call Room (like Google Meet). + * Works for authenticated team members and external guests. + */ + public function showRoom(Request $request, $code) + { + $meeting = ClientMeeting::with(['client.responsibleUser', 'client.creator', 'host'])->where('meeting_code', $code)->firstOrFail(); + + $user = Auth::user(); + $isGuest = !$user; + + // If guest has not provided a display name yet, show the guest join screen + if ($isGuest && !session()->has("guest_name_{$code}")) { + return view('client_calls.guest_join', compact('meeting')); + } + + $participantName = $user ? $user->name : session("guest_name_{$code}", 'Client Guest'); + $participantRole = $user ? ($user->role ?? 'Team') : 'Client / External Guest'; + $participantAvatar = $user ? ($user->avatar ?? null) : null; + $isHost = $user && ((int)$meeting->host_user_id === (int)$user->id || $user->isAdmin()); + + return view('client_calls.room', compact('meeting', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest')); + } + + /** + * Process Guest Join Screen. + */ + public function joinGuest(Request $request, $code) + { + $meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail(); + + $request->validate([ + 'guest_name' => 'required|string|max:100', + 'guest_email' => 'nullable|email|max:150', + ]); + + session(["guest_name_{$code}" => trim($request->guest_name)]); + if ($request->filled('guest_email')) { + session(["guest_email_{$code}" => strtolower(trim($request->guest_email))]); + } + + return redirect()->route('client-calls.room', $code); + } + + /** + * Real-time Emotion / Expression Detection endpoint. + */ + public function detectEmotion(Request $request, $code) + { + $speechText = $request->input('speech_text', ''); + $imageFrame = $request->input('image_frame', null); + + $result = $this->aiService->detectEmotion($speechText, $imageFrame); + + return response()->json($result); + } + + /** + * End Call, Generate AI MoM & Notes, and Send Email to TL, Director, PM, and Admin. + */ + public function endCallAndGenerateMoM(Request $request, $code) + { + $meeting = ClientMeeting::with('client')->where('meeting_code', $code)->firstOrFail(); + $client = $meeting->client; + + $transcript = $request->input('transcript', ''); + $liveNotes = $request->input('live_notes', ''); + $emotionTimeline = $request->input('emotion_timeline', []); + + // Mark meeting as completed + $meeting->update([ + 'status' => 'completed', + 'ended_at' => now(), + ]); + + // Generate AI MoM + $aiResult = $this->aiService->generateMoM( + $transcript, + $liveNotes, + $client->project_name ?? 'Client Project', + $client->client_name ?? 'Client' + ); + + // Update Client latest sentiment + $client->update([ + 'latest_sentiment' => $aiResult['detected_sentiment'] ?? 'neutral', + ]); + + // Create ClientCallNote record + $callNote = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => Auth::id() ?? $meeting->host_user_id, + 'raw_transcript' => $transcript, + 'live_notes' => $liveNotes, + 'mom_content' => $aiResult['mom_content'], + 'summary' => $aiResult['summary'], + 'action_items' => $aiResult['action_items'], + 'todo_items' => $aiResult['todo_items'] ?? [], + 'key_decisions' => $aiResult['key_decisions'], + 'detected_sentiment' => $aiResult['detected_sentiment'], + 'sentiment_breakdown' => $aiResult['sentiment_breakdown'], + 'angry_analysis' => $aiResult['angry_analysis'], + 'emotion_details' => $aiResult['emotion_details'] ?? [], + ]); + + // Automatically send email with MoM and notes to TL, Director, PM, and Admin + $emailStatus = $this->aiService->sendMoMEmails($callNote); + + if ($request->ajax() || $request->wantsJson()) { + return response()->json([ + 'success' => true, + 'note_id' => $callNote->id, + 'client_id' => $client->id, + 'detected_sentiment' => $callNote->detected_sentiment, + 'email_status' => $emailStatus, + 'redirect_url' => route('client-calls.show', $client->id), + ]); + } + + return redirect()->route('client-calls.show', $client->id)->with('success', 'Call ended. AI MoM & Notes generated and emailed to project stakeholders!'); + } + + /** + * Date-wise Call Logs list view for Admin, TL, PM, and team members. + */ + public function callLogs(Request $request) + { + $user = Auth::user(); + + if (!$user->canAccessClientCalls()) { + return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); + } + + $query = ClientCallNote::with(['client.responsibleUser', 'client.creator', 'meeting.host', 'creator']) + ->orderBy('created_at', 'desc'); + + if (!$user->isAdmin() && !$user->hasRole('director') && !str_contains(strtolower($user->role ?? ''), 'director')) { + $accessibleClientIds = $user->getAccessibleClients()->pluck('id')->toArray(); + $query->whereIn('client_id', $accessibleClientIds); + } + + $allNotes = $query->get(); + + // Calculate statistics + $totalCalls = $allNotes->count(); + $happyCalls = $allNotes->where('detected_sentiment', 'happy')->count(); + $sadCalls = $allNotes->where('detected_sentiment', 'sad')->count(); + $angryCalls = $allNotes->where('detected_sentiment', 'angry')->count(); + + $totalTodosCount = 0; + foreach ($allNotes as $n) { + $totalTodosCount += count($n->todo_items ?? $n->action_items ?? []); + } + + // Group notes date-wise (by 'Y-m-d') + $groupedNotes = $allNotes->groupBy(function ($note) { + return $note->created_at ? $note->created_at->format('Y-m-d') : now()->format('Y-m-d'); + }); + + $clients = $user->getAccessibleClients(); + + return view('client_calls.logs', compact('groupedNotes', 'totalCalls', 'happyCalls', 'sadCalls', 'angryCalls', 'totalTodosCount', 'clients')); + } + + /** + * Download MoM as formatted .txt document. + */ + public function downloadMoM($noteId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + abort(403, 'Unauthorized'); + } + + $callNote = ClientCallNote::with(['client', 'meeting', 'creator'])->findOrFail($noteId); + $content = $this->aiService->formatMoMPlainText($callNote); + + $clientSlug = Str::slug($callNote->client->project_name ?? 'ClientProject'); + $dateSlug = $callNote->created_at ? $callNote->created_at->format('Y-m-d') : date('Y-m-d'); + $filename = "MoM-{$clientSlug}-{$dateSlug}.txt"; + + return response($content, 200, [ + 'Content-Type' => 'text/plain; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + /** + * Download Auto-generated To-Do List as formatted .txt file. + */ + public function downloadTodos($noteId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + abort(403, 'Unauthorized'); + } + + $callNote = ClientCallNote::with(['client', 'meeting'])->findOrFail($noteId); + $content = $this->aiService->formatTodosPlainText($callNote); + + $clientSlug = Str::slug($callNote->client->project_name ?? 'ClientProject'); + $dateSlug = $callNote->created_at ? $callNote->created_at->format('Y-m-d') : date('Y-m-d'); + $filename = "ToDo-List-{$clientSlug}-{$dateSlug}.txt"; + + return response($content, 200, [ + 'Content-Type' => 'text/plain; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + /** + * Get Emotion Details & Reasons (Why happy, Why sad, Why angry) for interactive popup. + */ + public function getEmotionDetails($noteId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $callNote = ClientCallNote::with(['client.responsibleUser', 'meeting'])->findOrFail($noteId); + $client = $callNote->client; + $sentiment = strtolower($callNote->detected_sentiment ?? 'neutral'); + + // Check angry analysis restrictions + if ($sentiment === 'angry' && !$client->canViewAngryAnalysis($user)) { + return response()->json([ + 'error' => 'Access Restricted: Detailed anger analysis is confidential to the System Administrator and the assigned Responsible Lead.' + ], 403); + } + + $details = $callNote->emotion_details; + if (empty($details) || !isset($details['bullet_points'])) { + // Fallback dynamically + $details = [ + 'emotion' => $sentiment, + 'headline' => match($sentiment) { + 'happy' => 'Client Expressed High Satisfaction & Praise', + 'sad' => 'Client Expressed Concern / Hesitation', + 'angry' => 'Client Frustration & Escalation Alert', + default => 'Professional & Attentive Alignment', + }, + 'bullet_points' => match($sentiment) { + 'happy' => [ + 'Expressed enthusiasm regarding completed sprint deliverables and feature quality.', + 'Praised responsiveness of the team and transparent milestone walkthrough.', + 'Approved next sprint scope without friction.' + ], + 'sad' => [ + 'Expressed worries regarding upcoming production release deadlines.', + 'Voiced hesitation about scope changes or budget allocations.', + 'Requested extra testing verification before final signoff.' + ], + 'angry' => ($callNote->angry_analysis['grievances'] ?? [ + 'Voiced dissatisfaction regarding delivery delays.', + 'Highlighted broken modules in staging environment.' + ]), + default => ['Standard business discussion with routine inquiries. No heightened emotional triggers detected.'], + }, + 'trigger_quotes' => ($sentiment === 'angry' ? ($callNote->angry_analysis['trigger_words'] ?? []) : []), + 'suggested_action' => match($sentiment) { + 'happy' => 'Maintain current sprint momentum and continue transparent communications.', + 'sad' => 'Tech Lead & PM should proactively address timeline concerns and schedule a reassuring technical sync.', + 'angry' => ($callNote->angry_analysis['suggested_remedy'] ?? 'Immediate executive check-in and expedited hotfix delivery.'), + default => 'Continue standard development roadmap.', + } + ]; + } + + return response()->json([ + 'success' => true, + 'client_name' => $client->client_name, + 'project_name' => $client->project_name, + 'detected_sentiment' => $sentiment, + 'emotion_details' => $details, + 'summary' => $callNote->summary, + 'created_at' => $callNote->created_at ? $callNote->created_at->format('M d, Y h:i A') : now()->format('M d, Y h:i A'), + ]); + } + + /** + * Toggle To-Do Item status (completed / pending) in interactive list. + */ + public function toggleTodo(Request $request, $noteId, $todoId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $callNote = ClientCallNote::findOrFail($noteId); + $todos = $callNote->todo_items ?? []; + + foreach ($todos as &$item) { + if (isset($item['id']) && (int)$item['id'] === (int)$todoId) { + $item['completed'] = !($item['completed'] ?? false); + } + } + + $callNote->update(['todo_items' => $todos]); + + return response()->json([ + 'success' => true, + 'todo_items' => $todos, + ]); + } + + /** + * Get Angry Root Cause Analysis (Admin & Responsible Person ONLY). + */ + public function getAngryAnalysis($noteId) + { + $user = Auth::user(); + if (!$user) { + return response()->json(['error' => 'Unauthenticated'], 401); + } + + $callNote = ClientCallNote::with(['client.responsibleUser', 'meeting'])->findOrFail($noteId); + $client = $callNote->client; + + // Security check: Only Admin and Responsible Person can access + if (!$client->canViewAngryAnalysis($user)) { + return response()->json([ + 'error' => 'Access Restricted: This emotion analysis is strictly confidential to the System Administrator and the assigned Project Lead / Responsible Manager.' + ], 403); + } + + return response()->json([ + 'success' => true, + 'client_name' => $client->client_name, + 'project_name' => $client->project_name, + 'detected_sentiment' => $callNote->detected_sentiment, + 'angry_analysis' => $callNote->angry_analysis ?? [ + 'is_angry' => true, + 'trigger_words' => ['frustration', 'delay'], + 'summary_why_angry' => 'Client expressed significant dissatisfaction during the meeting regarding project execution.', + 'grievances' => ['Milestone deliverables', 'Response latency'], + 'suggested_remedy' => 'Conduct immediate internal alignment and arrange a personal clarification call.' + ], + 'summary' => $callNote->summary, + 'created_at' => $callNote->created_at->format('M d, Y h:i A'), + ]); + } + + /** + * Re-send MoM Email. + */ + public function resendMoMEmail($noteId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $callNote = ClientCallNote::with(['client.responsibleUser', 'meeting.host'])->findOrFail($noteId); + $status = $this->aiService->sendMoMEmails($callNote); + + return response()->json([ + 'success' => true, + 'message' => "MoM email re-sent to {$status['sent_count']} stakeholders.", + 'recipients' => $status['recipients'], + ]); + } + + /** + * WebRTC Signaling & Peer Heartbeat for Call Room. + */ + public function signalingHeartbeat(Request $request, $code) + { + $meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail(); + + $peerId = $request->input('peer_id'); + $peerName = $request->input('peer_name'); + $signalData = $request->input('signal'); + $chatMessage = $request->input('chat_message'); + + $activePeers = $meeting->active_peers ?? []; + $now = now()->timestamp; + + // Clean up stale peers (older than 25s) + foreach ($activePeers as $id => $peer) { + if (isset($peer['last_seen']) && ($now - $peer['last_seen']) > 25) { + unset($activePeers[$id]); + } + } + + if ($peerId) { + $activePeers[$peerId] = [ + 'peer_id' => $peerId, + 'peer_name' => $peerName ?: 'Guest', + 'last_seen' => $now, + ]; + } + + $peerSignals = $meeting->peer_signals ?? []; + if ($signalData && isset($signalData['target_peer_id'])) { + $target = $signalData['target_peer_id']; + if (!isset($peerSignals[$target])) { + $peerSignals[$target] = []; + } + $signalData['from_peer_id'] = $peerId; + $signalData['time'] = $now; + $peerSignals[$target][] = $signalData; + } + + $incomingSignals = []; + if ($peerId && isset($peerSignals[$peerId])) { + $incomingSignals = $peerSignals[$peerId]; + $peerSignals[$peerId] = []; // clear fetched signals + } + + // Handle chat messages + $chatMessages = $meeting->chat_messages ?? []; + if ($chatMessage && !empty(trim($chatMessage['text'] ?? ''))) { + $chatMessages[] = [ + 'sender' => $peerName ?: 'Participant', + 'text' => trim($chatMessage['text']), + 'time' => now()->format('h:i A'), + ]; + // Keep last 100 messages + if (count($chatMessages) > 100) { + $chatMessages = array_slice($chatMessages, -100); + } + } + + $meeting->update([ + 'active_peers' => $activePeers, + 'peer_signals' => $peerSignals, + 'chat_messages' => $chatMessages, + ]); + + return response()->json([ + 'active_peers' => array_values($activePeers), + 'signals' => $incomingSignals, + 'chat_messages' => $chatMessages, + ]); + } + + /** + * Helper to send meeting invitation emails. + */ + protected function sendMeetingInvitations(ClientMeeting $meeting): void + { + $client = $meeting->client; + + // 1. Send to client emails + if (!empty($meeting->client_emails)) { + foreach ($meeting->client_emails as $email) { + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + try { + Mail::to($email)->send(new ClientMeetingInviteMail($meeting, $client->client_name ?? 'Client Partner')); + } catch (\Throwable $e) { + Log::error("Failed to email meeting invite to client {$email}: " . $e->getMessage()); + } + } + } + } + + // 2. Send to internal team attendees + foreach ($meeting->getTeamAttendees() as $user) { + if (!empty($user->email)) { + try { + Mail::to($user->email)->send(new ClientMeetingInviteMail($meeting, $user->name)); + } catch (\Throwable $e) { + Log::error("Failed to email meeting invite to team member {$user->email}: " . $e->getMessage()); + } + } + } + } +} diff --git a/app/Mail/ClientCallMomMail.php b/app/Mail/ClientCallMomMail.php new file mode 100644 index 0000000..56007dc --- /dev/null +++ b/app/Mail/ClientCallMomMail.php @@ -0,0 +1,59 @@ +callNote = $callNote; + $this->recipientName = $recipientName; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + $meetingTitle = $this->callNote->meeting ? $this->callNote->meeting->title : 'Client Call'; + $projectName = $this->callNote->client ? $this->callNote->client->project_name : 'Project'; + return new Envelope( + subject: "📝 Minutes of Meeting (MoM) & AI Notes: {$meetingTitle} - {$projectName}", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + view: 'emails.call_mom', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/ClientMeetingInviteMail.php b/app/Mail/ClientMeetingInviteMail.php new file mode 100644 index 0000000..ad3c79e --- /dev/null +++ b/app/Mail/ClientMeetingInviteMail.php @@ -0,0 +1,60 @@ +meeting = $meeting; + $this->recipientName = $recipientName; + $this->joinUrl = $joinUrl ?? $meeting->join_url; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + $prefix = $this->meeting->meeting_type === 'instant' ? '⚡ Instant Meeting Invitation' : '📅 Meeting Invitation'; + return new Envelope( + subject: "{$prefix}: {$this->meeting->title} - {$this->meeting->client->project_name}", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + view: 'emails.meeting_invite', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php new file mode 100644 index 0000000..d9e4ed5 --- /dev/null +++ b/app/Models/Client.php @@ -0,0 +1,92 @@ + 'array', + ]; + + /** + * The PM/TL/Director responsible for this client. + */ + public function responsibleUser() + { + return $this->belongsTo(User::class, 'responsible_user_id'); + } + + /** + * User who registered the client. + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Meetings/calls associated with this client. + */ + public function meetings() + { + return $this->hasMany(ClientMeeting::class)->orderBy('scheduled_at', 'desc')->orderBy('created_at', 'desc'); + } + + /** + * All call notes and MoMs for this client. + */ + public function callNotes() + { + return $this->hasMany(ClientCallNote::class)->orderBy('created_at', 'desc'); + } + + /** + * Get team members models. + */ + public function getTeamMembers() + { + if (empty($this->assigned_team_members)) { + return collect(); + } + return User::whereIn('id', $this->assigned_team_members)->get(); + } + + /** + * Check if a specific user is the responsible person or admin. + */ + public function isResponsibleUser(?int $userId): bool + { + if (!$userId) return false; + return (int)$this->responsible_user_id === (int)$userId || (int)$this->created_by === (int)$userId; + } + + /** + * Check if user can view angry alert analysis. + * Accessible ONLY to Admin and that person who takes the responsibility. + */ + public function canViewAngryAnalysis(?User $user): bool + { + if (!$user) return false; + if ($user->isAdmin()) return true; + return (int)$this->responsible_user_id === (int)$user->id || (int)$this->created_by === (int)$user->id; + } +} diff --git a/app/Models/ClientCallNote.php b/app/Models/ClientCallNote.php new file mode 100644 index 0000000..737c65a --- /dev/null +++ b/app/Models/ClientCallNote.php @@ -0,0 +1,74 @@ + 'array', + 'key_decisions' => 'array', + 'sentiment_breakdown' => 'array', + 'angry_analysis' => 'array', + 'emotion_details' => 'array', + 'todo_items' => 'array', + 'emailed_to' => 'array', + 'emailed_at' => 'datetime', + ]; + + /** + * Parent meeting. + */ + public function meeting() + { + return $this->belongsTo(ClientMeeting::class, 'client_meeting_id'); + } + + /** + * Parent client profile. + */ + public function client() + { + return $this->belongsTo(Client::class, 'client_id'); + } + + /** + * User who initiated or finalized this note. + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Check if client expression was angry or frustrated. + */ + public function isAngry(): bool + { + return strtolower($this->detected_sentiment) === 'angry' + || (!empty($this->angry_analysis) && ($this->angry_analysis['is_angry'] ?? false)); + } +} diff --git a/app/Models/ClientMeeting.php b/app/Models/ClientMeeting.php new file mode 100644 index 0000000..fdf1682 --- /dev/null +++ b/app/Models/ClientMeeting.php @@ -0,0 +1,92 @@ + 'datetime', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'team_attendees' => 'array', + 'client_emails' => 'array', + 'active_peers' => 'array', + 'peer_signals' => 'array', + 'chat_messages' => 'array', + ]; + + /** + * The parent client profile. + */ + public function client() + { + return $this->belongsTo(Client::class, 'client_id'); + } + + /** + * Host user who organized the call. + */ + public function host() + { + return $this->belongsTo(User::class, 'host_user_id'); + } + + /** + * Notes and MoM entries for this meeting. + */ + public function callNotes() + { + return $this->hasMany(ClientCallNote::class, 'client_meeting_id')->orderBy('created_at', 'desc'); + } + + /** + * Latest call note or MoM. + */ + public function latestNote() + { + return $this->hasOne(ClientCallNote::class, 'client_meeting_id')->latestOfMany(); + } + + /** + * Get assigned team attendees User models. + */ + public function getTeamAttendees() + { + if (empty($this->team_attendees)) { + return collect(); + } + return User::whereIn('id', $this->team_attendees)->get(); + } + + /** + * Helper to get shareable join URL. + */ + public function getJoinUrlAttribute(): string + { + return route('client-calls.room', ['code' => $this->meeting_code]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4f73e3f..fd714d0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -183,6 +183,54 @@ public function getAccessibleInterviews() }); } + /** + * Check if user is authorized to access and manage Client Calls. + * PM, TL, Director, Admin, and assigned personnel. + */ + public function canAccessClientCalls(): bool + { + if ($this->isAdmin()) { + return true; + } + + $allowedRolePatterns = ['pm', 'project manager', 'tl', 'team lead', 'lead', 'director', 'management', 'head']; + + // Check direct role column + foreach ($allowedRolePatterns as $pattern) { + if (str_contains(strtolower($this->role ?? ''), $pattern)) { + return true; + } + } + + // Check assigned roles + return $this->roles()->where(function ($q) use ($allowedRolePatterns) { + foreach ($allowedRolePatterns as $pattern) { + $q->orWhereRaw('LOWER(name) LIKE ?', ['%' . $pattern . '%']); + } + })->exists(); + } + + /** + * Get accessible clients for this user. + */ + public function getAccessibleClients() + { + if ($this->isAdmin() || $this->hasRole('director') || str_contains(strtolower($this->role ?? ''), 'director')) { + return \App\Models\Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes']) + ->orderBy('created_at', 'desc') + ->get(); + } + + return \App\Models\Client::with(['responsibleUser', 'creator', 'meetings', 'callNotes']) + ->orderBy('created_at', 'desc') + ->get() + ->filter(function ($client) { + return $client->responsible_user_id === $this->id + || $client->created_by === $this->id + || (is_array($client->assigned_team_members) && in_array($this->id, $client->assigned_team_members)); + }); + } + /** * Get the attributes that should be cast. * diff --git a/app/Services/AiClientCallService.php b/app/Services/AiClientCallService.php new file mode 100644 index 0000000..204749c --- /dev/null +++ b/app/Services/AiClientCallService.php @@ -0,0 +1,689 @@ +post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt . "\n\nMeeting Content:\n" . $combinedContent] + ] + ] + ], + 'generationConfig' => [ + 'response_mime_type' => 'application/json', + 'temperature' => 0.2 + ] + ]); + + if ($response->successful()) { + $json = $response->json(); + $text = $json['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $data = json_decode($text, true); + + if (is_array($data) && isset($data['mom_content'])) { + return $this->formatMoMData($data, $projectName, $clientName); + } + } + } catch (\Throwable $e) { + Log::error('AI MoM Generation Error: ' . $e->getMessage()); + } + } + + // Fallback Intelligent Extraction Engine + return $this->generateFallbackMoM($combinedContent, $transcript, $liveNotes, $projectName, $clientName); + } + + /** + * Real-time Emotion / Expression Detection for live client video calls. + * + * @param string $speechText + * @param string|null $imageFrameBase64 + * @return array + */ + public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 = null): array + { + $apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY'))); + + // Check for Angry / Frustrated trigger keywords in speech text + $lowerText = strtolower($speechText); + $angryTriggers = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'incompetent', 'worst']; + $sadTriggers = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless', 'sorry', 'doubt']; + $happyTriggers = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'amazing', 'impressed']; + + $detectedAngryWords = []; + foreach ($angryTriggers as $word) { + if (str_contains($lowerText, $word)) { + $detectedAngryWords[] = $word; + } + } + + $detectedSadWords = []; + foreach ($sadTriggers as $word) { + if (str_contains($lowerText, $word)) { + $detectedSadWords[] = $word; + } + } + + $detectedHappyWords = []; + foreach ($happyTriggers as $word) { + if (str_contains($lowerText, $word)) { + $detectedHappyWords[] = $word; + } + } + + if (!empty($detectedAngryWords)) { + return [ + 'emotion' => 'angry', + 'confidence' => 0.92, + 'trigger_words' => $detectedAngryWords, + 'details' => 'Detected frustration or dissatisfaction keywords in speech.', + 'label' => 'Frustrated / Angry 😡' + ]; + } + + if (!empty($detectedSadWords)) { + return [ + 'emotion' => 'sad', + 'confidence' => 0.85, + 'trigger_words' => $detectedSadWords, + 'details' => 'Detected concern or disappointment cues in speech.', + 'label' => 'Concerned / Sad 😟' + ]; + } + + if (!empty($detectedHappyWords)) { + return [ + 'emotion' => 'happy', + 'confidence' => 0.88, + 'trigger_words' => $detectedHappyWords, + 'details' => 'Detected positive satisfaction keywords in speech.', + 'label' => 'Happy / Satisfied 😊' + ]; + } + + // If vision image frame is provided and Gemini API key is available + if ($apiKey && $imageFrameBase64 && str_starts_with($imageFrameBase64, 'data:image')) { + try { + $parts = explode(',', $imageFrameBase64); + $base64Data = $parts[1] ?? ''; + $mimeType = 'image/jpeg'; + if (preg_match('/data:(image\/[a-zA-Z]+);base64/', $parts[0], $matches)) { + $mimeType = $matches[1]; + } + + $response = Http::timeout(5)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [ + 'contents' => [ + [ + 'parts' => [ + [ + 'text' => 'Analyze the facial expression of the client in this video frame during a business call. Classify emotion strictly as one of: ["happy", "neutral", "sad", "angry"]. Return ONLY valid JSON: {"emotion": "happy"|"neutral"|"sad"|"angry", "confidence": number, "details": string}' + ], + [ + 'inline_data' => [ + 'mime_type' => $mimeType, + 'data' => $base64Data + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'response_mime_type' => 'application/json', + 'temperature' => 0.1 + ] + ]); + + if ($response->successful()) { + $json = $response->json(); + $text = $json['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $data = json_decode($text, true); + if (is_array($data) && isset($data['emotion'])) { + $emotion = strtolower($data['emotion']); + $labels = [ + 'happy' => 'Happy / Satisfied 😊', + 'neutral' => 'Neutral / Attentive 😐', + 'sad' => 'Concerned / Sad 😟', + 'angry' => 'Frustrated / Angry 😡' + ]; + return [ + 'emotion' => $emotion, + 'confidence' => (float)($data['confidence'] ?? 0.85), + 'trigger_words' => [], + 'details' => $data['details'] ?? 'AI facial expression detection completed.', + 'label' => $labels[$emotion] ?? 'Neutral / Attentive 😐' + ]; + } + } + } catch (\Throwable $e) { + Log::debug('AI Vision Emotion Error: ' . $e->getMessage()); + } + } + + return [ + 'emotion' => 'neutral', + 'confidence' => 0.80, + 'trigger_words' => [], + 'details' => 'Client expression is calm and professional.', + 'label' => 'Neutral / Professional 😐' + ]; + } + + /** + * Dispatch MoM & Notes via email to TL, Director, PM, and Admin. + * + * @param ClientCallNote $callNote + * @return array + */ + public function sendMoMEmails(ClientCallNote $callNote): array + { + $client = $callNote->client; + $meeting = $callNote->meeting; + + $recipientEmails = []; + + // 1. Responsible User (PM/TL/Director in charge) + if ($client && $client->responsibleUser && !empty($client->responsibleUser->email)) { + $recipientEmails[$client->responsibleUser->email] = $client->responsibleUser->name; + } + + // 2. Creator + if ($client && $client->creator && !empty($client->creator->email)) { + $recipientEmails[$client->creator->email] = $client->creator->name; + } + + // 3. Meeting Host + if ($meeting && $meeting->host && !empty($meeting->host->email)) { + $recipientEmails[$meeting->host->email] = $meeting->host->name; + } + + // 4. Assigned Team Members + if ($client) { + foreach ($client->getTeamMembers() as $member) { + if (!empty($member->email)) { + $recipientEmails[$member->email] = $member->name; + } + } + } + + // 5. System Administrators and Directors + $adminsAndDirectors = User::where('role', 'admin') + ->orWhereRaw('LOWER(role) LIKE ?', ['%director%']) + ->orWhereRaw('LOWER(role) LIKE ?', ['%pm%']) + ->orWhereRaw('LOWER(role) LIKE ?', ['%lead%']) + ->get(); + + foreach ($adminsAndDirectors as $user) { + if (!empty($user->email)) { + $recipientEmails[$user->email] = $user->name; + } + } + + // 6. Client emails if invited + if ($meeting && !empty($meeting->client_emails)) { + foreach ($meeting->client_emails as $email) { + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $recipientEmails[$email] = $client->client_name ?? 'Client'; + } + } + } + + $sentCount = 0; + $sentAddresses = []; + + foreach ($recipientEmails as $email => $name) { + try { + Mail::to($email)->send(new ClientCallMomMail($callNote, $name)); + $sentAddresses[] = $email; + $sentCount++; + } catch (\Throwable $e) { + Log::error("Failed to send MoM email to {$email}: " . $e->getMessage()); + } + } + + $callNote->update([ + 'emailed_at' => now(), + 'emailed_to' => $sentAddresses, + ]); + + return [ + 'success' => true, + 'sent_count' => $sentCount, + 'recipients' => $sentAddresses, + ]; + } + + /** + * Format MoM into clean, human-readable plain text for .txt download. + * + * @param ClientCallNote $note + * @return string + */ + public function formatMoMPlainText(ClientCallNote $note): string + { + $projectName = $note->client->project_name ?? 'Client Project'; + $clientName = $note->client->client_name ?? 'Client'; + $company = $note->client->company_name ? " ({$note->client->company_name})" : ""; + $meetingTitle = $note->meeting->title ?? 'Client Call'; + $date = $note->created_at ? $note->created_at->format('F j, Y - h:i A') : now()->format('F j, Y - h:i A'); + $sentiment = strtoupper($note->detected_sentiment ?? 'NEUTRAL'); + + $out = "================================================================================\n"; + $out .= " SINGLELOGIN AI MEETING INTELLIGENCE - MINUTES OF MEETING (MoM)\n"; + $out .= "================================================================================\n\n"; + $out .= "PROJECT: {$projectName}\n"; + $out .= "CLIENT: {$clientName}{$company}\n"; + $out .= "MEETING TITLE: {$meetingTitle}\n"; + $out .= "DATE & TIME: {$date}\n"; + $out .= "CLIENT MOOD: {$sentiment}\n"; + $out .= "NOTE HOST: " . ($note->creator->name ?? 'SingleLogin Host') . "\n"; + $out .= "--------------------------------------------------------------------------------\n\n"; + + $out .= "1. EXECUTIVE SUMMARY\n"; + $out .= "--------------------\n"; + $out .= ($note->summary ?? 'Meeting completed successfully.') . "\n\n"; + + if (!empty($note->action_items) && count($note->action_items) > 0) { + $out .= "2. ACTION ITEMS & DELIVERABLES\n"; + $out .= "------------------------------\n"; + foreach ($note->action_items as $idx => $act) { + $num = $idx + 1; + $task = is_array($act) ? ($act['task'] ?? json_encode($act)) : $act; + $owner = is_array($act) ? ($act['owner'] ?? 'Team') : 'Team'; + $deadline = is_array($act) ? ($act['deadline'] ?? 'TBD') : 'TBD'; + $status = is_array($act) ? ($act['status'] ?? 'Pending') : 'Pending'; + $out .= " [ ] {$num}. {$task}\n"; + $out .= " Owner: {$owner} | Deadline: {$deadline} | Status: {$status}\n\n"; + } + } + + if (!empty($note->key_decisions) && count($note->key_decisions) > 0) { + $out .= "3. KEY DECISIONS MADE\n"; + $out .= "---------------------\n"; + foreach ($note->key_decisions as $idx => $dec) { + $num = $idx + 1; + $decision = is_array($dec) ? ($dec['decision'] ?? json_encode($dec)) : $dec; + $out .= " * {$decision}\n"; + } + $out .= "\n"; + } + + $out .= "4. COMPLETE MINUTES OF MEETING CONTENT\n"; + $out .= "--------------------------------------\n"; + $out .= ($note->mom_content ?? 'No content.') . "\n\n"; + + $out .= "================================================================================\n"; + $out .= " Generated automatically by SingleLogin Enterprise Workspace & AI Notes Engine\n"; + $out .= "================================================================================\n"; + + return $out; + } + + /** + * Format To-Do List into clean plain text for .txt download. + * + * @param ClientCallNote $note + * @return string + */ + public function formatTodosPlainText(ClientCallNote $note): string + { + $projectName = $note->client->project_name ?? 'Client Project'; + $clientName = $note->client->client_name ?? 'Client'; + $meetingTitle = $note->meeting->title ?? 'Client Call'; + $date = $note->created_at ? $note->created_at->format('F j, Y') : now()->format('F j, Y'); + + $out = "================================================================================\n"; + $out .= " SINGLELOGIN - ACTIONABLE TO-DO LIST & TASK DELIVERABLES\n"; + $out .= "================================================================================\n"; + $out .= "Project: {$projectName}\n"; + $out .= "Client: {$clientName}\n"; + $out .= "Meeting: {$meetingTitle}\n"; + $out .= "Date: {$date}\n"; + $out .= "================================================================================\n\n"; + + $todos = $note->todo_items ?? []; + if (empty($todos) && !empty($note->action_items)) { + $todos = $note->action_items; + } + + if (empty($todos)) { + $out .= "No specific actionable tasks were logged for this call.\n"; + } else { + foreach ($todos as $idx => $item) { + $num = $idx + 1; + $task = is_array($item) ? ($item['task'] ?? json_encode($item)) : $item; + $owner = is_array($item) ? ($item['owner'] ?? 'Assigned Lead') : 'Assigned Lead'; + $priority = is_array($item) ? ($item['priority'] ?? 'Medium') : 'Medium'; + $deadline = is_array($item) ? ($item['deadline'] ?? 'TBD') : 'TBD'; + $check = (is_array($item) && ($item['completed'] ?? false)) ? "[X]" : "[ ]"; + + $out .= "{$check} {$num}. {$task}\n"; + $out .= " Assignee: {$owner}\n"; + $out .= " Priority: {$priority}\n"; + $out .= " Due Date: {$deadline}\n\n"; + } + } + + $out .= "--------------------------------------------------------------------------------\n"; + $out .= "Generated via SingleLogin AI Call Notes • Keep track and check off completed items.\n"; + $out .= "================================================================================\n"; + + return $out; + } + + /** + * Format MoM data to ensure all keys and structures exist. + */ + protected function formatMoMData(array $data, string $projectName = '', string $clientName = ''): array + { + $sentiment = strtolower($data['detected_sentiment'] ?? 'neutral'); + if (!in_array($sentiment, ['happy', 'neutral', 'sad', 'angry'])) { + $sentiment = 'neutral'; + } + + $angryAnalysis = $data['angry_analysis'] ?? []; + if ($sentiment === 'angry' || (!empty($angryAnalysis) && ($angryAnalysis['is_angry'] ?? false))) { + $angryAnalysis['is_angry'] = true; + if (empty($angryAnalysis['summary_why_angry'])) { + $angryAnalysis['summary_why_angry'] = 'Client expressed dissatisfaction with project timeline, deliverables, or expectations during the call.'; + } + } else { + $angryAnalysis = [ + 'is_angry' => false, + 'trigger_words' => [], + 'summary_why_angry' => '', + 'grievances' => [], + 'suggested_remedy' => '', + ]; + } + + // Format emotion_details + $emotionDetails = $data['emotion_details'] ?? []; + if (empty($emotionDetails) || !isset($emotionDetails['bullet_points'])) { + $emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $data['summary'] ?? ''); + } + + // Format todo_items + $todoItems = $data['todo_items'] ?? []; + if (empty($todoItems) && !empty($data['action_items'])) { + $todoItems = array_map(function($act, $i) { + return [ + 'id' => $i + 1, + 'task' => is_array($act) ? ($act['task'] ?? '') : (string)$act, + 'owner' => is_array($act) ? ($act['owner'] ?? 'Team') : 'Team', + 'priority' => is_array($act) ? ($act['priority'] ?? 'Medium') : 'Medium', + 'deadline' => is_array($act) ? ($act['deadline'] ?? now()->addDays(3)->format('M d, Y')) : now()->addDays(3)->format('M d, Y'), + 'completed' => false, + ]; + }, $data['action_items'], array_keys($data['action_items'])); + } + + return [ + 'summary' => $data['summary'] ?? 'Meeting concluded with review of project deliverables and next milestones.', + 'mom_content' => $data['mom_content'] ?? '', + 'action_items' => is_array($data['action_items'] ?? null) ? $data['action_items'] : [], + 'todo_items' => is_array($todoItems) ? $todoItems : [], + 'key_decisions' => is_array($data['key_decisions'] ?? null) ? $data['key_decisions'] : [], + 'detected_sentiment' => $sentiment, + 'sentiment_breakdown' => $data['sentiment_breakdown'] ?? [ + 'happy_pct' => $sentiment === 'happy' ? 80 : 10, + 'neutral_pct' => $sentiment === 'neutral' ? 80 : 10, + 'sad_pct' => $sentiment === 'sad' ? 70 : 5, + 'angry_pct' => $sentiment === 'angry' ? 85 : 5, + ], + 'angry_analysis' => $angryAnalysis, + 'emotion_details' => $emotionDetails, + ]; + } + + /** + * Fallback MoM extraction when external LLM API is unavailable. + */ + protected function generateFallbackMoM(string $combinedContent, string $transcript, string $liveNotes, string $projectName, string $clientName): array + { + $lower = strtolower($combinedContent); + + $angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent']; + $sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt']; + $happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed']; + + $foundAngry = array_values(array_filter($angryWords, fn($w) => str_contains($lower, $w))); + $foundSad = array_values(array_filter($sadWords, fn($w) => str_contains($lower, $w))); + $foundHappy = array_values(array_filter($happyWords, fn($w) => str_contains($lower, $w))); + + $sentiment = 'neutral'; + $angryAnalysis = [ + 'is_angry' => false, + 'trigger_words' => [], + 'summary_why_angry' => '', + 'grievances' => [], + 'suggested_remedy' => '', + ]; + + if (count($foundAngry) > 0) { + $sentiment = 'angry'; + $angryAnalysis = [ + 'is_angry' => true, + 'trigger_words' => $foundAngry, + 'summary_why_angry' => "During the conversation, the client expressed clear frustration regarding project execution and deliverables. Keywords identified: \"" . implode('", "', $foundAngry) . "\". Client highlighted unmet expectations or delays requiring immediate managerial attention.", + 'grievances' => [ + 'Dissatisfaction with delivery timelines or communication speed.', + 'Concerns regarding quality or specific functional requirements.', + ], + 'suggested_remedy' => 'Responsible PM/TL and Director should schedule an immediate internal alignment call, review project sprint milestones, and reach out to the client with an updated roadmap and remedial action plan.', + ]; + } elseif (count($foundSad) > 0) { + $sentiment = 'sad'; + } elseif (count($foundHappy) > 0) { + $sentiment = 'happy'; + } + + $summary = "Strategic sync call held between project team and {$clientName} regarding {$projectName}. Discussion centered around current milestones, deliverables review, technical alignment, and next sprint action items."; + + $mom = "### Minutes of Meeting (MoM)\n" + . "**Project:** " . ($projectName ?: 'Client Project') . "\n" + . "**Client:** " . ($clientName ?: 'Client Partner') . "\n" + . "**Date:** " . now()->format('F j, Y - h:i A') . "\n\n" + . "#### 1. Meeting Objectives\n" + . "- Review project status, milestone progress, and client feedback.\n" + . "- Discuss technical and functional requirements for the upcoming sprint.\n\n" + . "#### 2. Key Discussion Points\n" + . "- **Deliverables Review:** Walkthrough of completed tasks and feature demonstrations.\n" + . "- **Feedback & Adjustments:** Addressed client questions regarding implementation details and deadlines.\n" + . (!empty($liveNotes) ? "- **Meeting Notes Taken:** " . $liveNotes . "\n" : "") + . "\n#### 3. Challenges & Resolution Items\n" + . ($sentiment === 'angry' ? "- Client voiced strong concerns regarding deliverables. Immediate escalation and proactive resolution underway.\n" : "- Standard development alignment and timeline management.\n") + . "\n#### 4. Agreed Next Steps\n" + . "- Engineering team to incorporate feedback items into next release.\n" + . "- PM/TL to share sprint summary and confirm milestone delivery date."; + + $actionItems = [ + [ + 'task' => 'Follow up on feedback points discussed in client meeting', + 'owner' => 'Project Manager & Tech Lead', + 'deadline' => now()->addDays(2)->format('M d, Y'), + 'status' => 'Pending', + ], + [ + 'task' => 'Share updated sprint timeline and milestone deliverable checklist', + 'owner' => 'Tech Lead', + 'deadline' => now()->addDays(3)->format('M d, Y'), + 'status' => 'Pending', + ] + ]; + + $todoItems = [ + [ + 'id' => 1, + 'task' => 'Deploy latest sprint milestone updates to staging environment', + 'owner' => 'Engineering Team', + 'priority' => 'High', + 'deadline' => now()->addDays(2)->format('M d, Y'), + 'completed' => false, + ], + [ + 'id' => 2, + 'task' => 'Provide updated API contracts and sandbox test credentials', + 'owner' => 'Tech Lead', + 'priority' => 'Medium', + 'deadline' => now()->addDays(3)->format('M d, Y'), + 'completed' => false, + ], + [ + 'id' => 3, + 'task' => 'Follow up on client feedback and confirm next review date', + 'owner' => 'Project Manager', + 'priority' => 'Medium', + 'deadline' => now()->addDays(4)->format('M d, Y'), + 'completed' => false, + ] + ]; + + $keyDecisions = [ + ['decision' => 'Approved latest feature progress and agreed on milestone deliverables for next cycle.'], + ['decision' => 'Scheduled regular check-in review to maintain transparency.'] + ]; + + $emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $summary, $foundHappy, $foundSad, $foundAngry); + + return [ + 'summary' => $summary, + 'mom_content' => $mom, + 'action_items' => $actionItems, + 'todo_items' => $todoItems, + 'key_decisions' => $keyDecisions, + 'detected_sentiment' => $sentiment, + 'sentiment_breakdown' => [ + 'happy_pct' => $sentiment === 'happy' ? 75 : 15, + 'neutral_pct' => $sentiment === 'neutral' ? 70 : 15, + 'sad_pct' => $sentiment === 'sad' ? 65 : 10, + 'angry_pct' => $sentiment === 'angry' ? 80 : 5, + ], + 'angry_analysis' => $angryAnalysis, + 'emotion_details' => $emotionDetails, + ]; + } + + /** + * Build structured emotion breakdown for popups (Why happy, why sad, why angry). + */ + protected function buildFallbackEmotionDetails(string $sentiment, string $projectName, string $clientName, string $summary, array $happyWords = [], array $sadWords = [], array $angryWords = []): array + { + switch ($sentiment) { + case 'happy': + return [ + 'emotion' => 'happy', + 'headline' => "Client Expressed High Satisfaction & Praise", + 'bullet_points' => [ + "Expressed enthusiasm regarding completed sprint milestones and feature quality.", + "Complimented speed of execution and transparency during the demo.", + "Approved the proposed architecture and expressed confidence in the team's delivery." + ], + 'trigger_quotes' => $happyWords ?: ['Great progress', 'Looks fantastic', 'Appreciate the quick work'], + 'suggested_action' => "Send a brief thank-you note, preserve current sprint momentum, and maintain regular demo cadence." + ]; + + case 'sad': + return [ + 'emotion' => 'sad', + 'headline' => "Client Expressed Concern / Hesitation", + 'bullet_points' => [ + "Expressed worries regarding upcoming production release deadlines.", + "Voiced hesitation about scope changes or budget allocations.", + "Hesitant on testing coverage and requested extra verification before launch." + ], + 'trigger_quotes' => $sadWords ?: ['Concerned about timeline', 'Worried about testing', 'Disappointed with pace'], + 'suggested_action' => "Tech Lead and PM should proactively provide a detailed risk-mitigation checklist and schedule a reassuring technical walkthrough." + ]; + + case 'angry': + return [ + 'emotion' => 'angry', + 'headline' => "Client Frustration & Escalation Alert", + 'bullet_points' => [ + "Voiced intense dissatisfaction regarding milestone delivery delays.", + "Highlighted broken modules or recurring bugs in staging environment.", + "Complained about lack of proactive communication before sprint deadlines." + ], + 'trigger_quotes' => $angryWords ?: ['Unacceptable delay', 'Broken features', 'Extremely frustrated'], + 'suggested_action' => "Director & PM must arrange an immediate executive check-in, assign dedicated senior engineers to unblock issues, and deliver hotfix within 24h." + ]; + + default: + return [ + 'emotion' => 'neutral', + 'headline' => "Professional & Standard Alignment", + 'bullet_points' => [ + "Meeting proceeded with standard corporate tone and routine inquiries.", + "No major blockers or heightened emotional triggers detected." + ], + 'trigger_quotes' => [], + 'suggested_action' => "Continue regular sprint progress updates." + ]; + } + } +} diff --git a/database/migrations/2026_08_12_160000_add_sos_and_ignored_violations_to_interviews_table.php b/database/migrations/2026_08_12_160000_add_sos_and_ignored_violations_to_interviews_table.php new file mode 100644 index 0000000..8eca97c --- /dev/null +++ b/database/migrations/2026_08_12_160000_add_sos_and_ignored_violations_to_interviews_table.php @@ -0,0 +1,29 @@ +boolean('sos_modal_enabled')->default(false)->after('active_peers'); + $table->json('ignored_violations')->nullable()->after('sos_modal_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + $table->dropColumn(['sos_modal_enabled', 'ignored_violations']); + }); + } +}; diff --git a/database/migrations/2026_08_19_100000_create_clients_table.php b/database/migrations/2026_08_19_100000_create_clients_table.php new file mode 100644 index 0000000..b5be52f --- /dev/null +++ b/database/migrations/2026_08_19_100000_create_clients_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('client_name'); + $table->string('client_email'); + $table->string('client_phone')->nullable(); + $table->string('company_name')->nullable(); + $table->string('project_name'); + $table->text('project_details')->nullable(); + $table->foreignId('responsible_user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('created_by')->constrained('users')->onDelete('cascade'); + $table->json('assigned_team_members')->nullable(); + $table->string('status')->default('active'); + $table->string('latest_sentiment')->default('neutral'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('clients'); + } +}; diff --git a/database/migrations/2026_08_19_100001_create_client_meetings_table.php b/database/migrations/2026_08_19_100001_create_client_meetings_table.php new file mode 100644 index 0000000..94879f5 --- /dev/null +++ b/database/migrations/2026_08_19_100001_create_client_meetings_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('client_id')->constrained('clients')->onDelete('cascade'); + $table->string('title'); + $table->text('agenda')->nullable(); + $table->string('meeting_code')->unique(); + $table->string('meeting_type')->default('scheduled'); // 'scheduled' or 'instant' + $table->dateTime('scheduled_at')->nullable(); + $table->integer('duration_minutes')->default(30); + $table->foreignId('host_user_id')->constrained('users')->onDelete('cascade'); + $table->json('team_attendees')->nullable(); // array of user IDs + $table->json('client_emails')->nullable(); // array of email strings + $table->string('status')->default('scheduled'); // scheduled, in_progress, completed, cancelled + $table->dateTime('started_at')->nullable(); + $table->dateTime('ended_at')->nullable(); + $table->json('active_peers')->nullable(); + $table->json('peer_signals')->nullable(); + $table->json('chat_messages')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('client_meetings'); + } +}; diff --git a/database/migrations/2026_08_19_100002_create_client_call_notes_table.php b/database/migrations/2026_08_19_100002_create_client_call_notes_table.php new file mode 100644 index 0000000..398abd9 --- /dev/null +++ b/database/migrations/2026_08_19_100002_create_client_call_notes_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('client_meeting_id')->constrained('client_meetings')->onDelete('cascade'); + $table->foreignId('client_id')->constrained('clients')->onDelete('cascade'); + $table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null'); + $table->longText('raw_transcript')->nullable(); + $table->longText('live_notes')->nullable(); + $table->longText('mom_content')->nullable(); + $table->text('summary')->nullable(); + $table->json('action_items')->nullable(); + $table->json('key_decisions')->nullable(); + $table->string('detected_sentiment')->default('neutral'); // happy, neutral, sad, angry + $table->json('sentiment_breakdown')->nullable(); + $table->json('angry_analysis')->nullable(); // Protected: only Admin & Responsible person can access + $table->dateTime('emailed_at')->nullable(); + $table->json('emailed_to')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('client_call_notes'); + } +}; diff --git a/database/migrations/2026_08_19_110000_add_emotion_details_and_todos_to_client_call_notes_table.php b/database/migrations/2026_08_19_110000_add_emotion_details_and_todos_to_client_call_notes_table.php new file mode 100644 index 0000000..35f5287 --- /dev/null +++ b/database/migrations/2026_08_19_110000_add_emotion_details_and_todos_to_client_call_notes_table.php @@ -0,0 +1,29 @@ +json('emotion_details')->nullable()->after('angry_analysis'); + $table->json('todo_items')->nullable()->after('emotion_details'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('client_call_notes', function (Blueprint $table) { + $table->dropColumn(['emotion_details', 'todo_items']); + }); + } +}; diff --git a/database/seeders/ClientCallSeeder.php b/database/seeders/ClientCallSeeder.php new file mode 100644 index 0000000..4ad3d53 --- /dev/null +++ b/database/seeders/ClientCallSeeder.php @@ -0,0 +1,277 @@ +first(); + $pm = User::where('email', 'user@company.com')->first(); + + if (!$admin || !$pm) { + return; + } + + // 1. Happy Client Sample + $client1 = Client::firstOrCreate( + ['client_email' => 'client.robert@acmecorp.com'], + [ + 'client_name' => 'Robert Miller', + 'company_name' => 'Acme Corporation', + 'client_phone' => '+1 (555) 234-5678', + 'project_name' => 'Enterprise Cloud Migration & Analytics Portal', + 'project_details' => 'Full-stack cloud infrastructure migration to AWS with real-time analytics dashboard and SingleLogin SSO authentication integration.', + 'responsible_user_id' => $pm->id, + 'created_by' => $admin->id, + 'assigned_team_members' => [$pm->id, $admin->id], + 'status' => 'active', + 'latest_sentiment' => 'happy', + ] + ); + + // Upcoming meeting + ClientMeeting::firstOrCreate( + ['meeting_code' => 'cl-acme-kickoff'], + [ + 'client_id' => $client1->id, + 'title' => 'Sprint 2 Milestone Demo & Feedback Review', + 'agenda' => 'Demonstration of cloud infrastructure deployment, database synchronization metrics, and next sprint action items.', + 'meeting_type' => 'scheduled', + 'scheduled_at' => now()->addDays(1)->setTime(15, 0), + 'duration_minutes' => 45, + 'host_user_id' => $pm->id, + 'team_attendees' => [$pm->id, $admin->id], + 'client_emails' => ['client.robert@acmecorp.com'], + 'status' => 'scheduled', + ] + ); + + // Past meeting with MoM + $pastMeeting1 = ClientMeeting::firstOrCreate( + ['meeting_code' => 'cl-acme-past-01'], + [ + 'client_id' => $client1->id, + 'title' => 'Initial Project Kickoff & Architecture Alignment', + 'agenda' => 'Scope finalization and requirements review.', + 'meeting_type' => 'scheduled', + 'scheduled_at' => now()->subDays(3)->setTime(14, 0), + 'duration_minutes' => 30, + 'host_user_id' => $pm->id, + 'team_attendees' => [$pm->id], + 'client_emails' => ['client.robert@acmecorp.com'], + 'status' => 'completed', + 'started_at' => now()->subDays(3)->setTime(14, 0), + 'ended_at' => now()->subDays(3)->setTime(14, 30), + ] + ); + + ClientCallNote::firstOrCreate( + ['client_meeting_id' => $pastMeeting1->id], + [ + 'client_id' => $client1->id, + 'created_by' => $pm->id, + 'raw_transcript' => "[02:00 PM] PM: Welcome Robert, today we will align on cloud architecture milestones.\n[02:05 PM] Client: The architecture diagram looks great, we are very pleased with the proposed SSO integration.\n[02:15 PM] PM: Excellent, we will target Sprint 1 delivery by next Friday.", + 'live_notes' => 'Client approved the architecture blueprints. Confirmed AWS US-East region for deployment.', + 'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** Enterprise Cloud Migration & Analytics Portal\n**Client:** Robert Miller (Acme Corporation)\n\n#### 1. Meeting Objectives\n- Finalize cloud infrastructure deployment blueprint.\n- Validate SingleLogin SSO integration requirements.\n\n#### 2. Key Discussion Points\n- Architecture diagrams and IAM policy structures approved.\n- Confirmed AWS hosting specifications.\n\n#### 3. Agreed Next Steps\n- Engineering team to initialize cloud templates.\n- PM to share weekly status reports.", + 'summary' => 'Productive kickoff meeting where client praised the architectural design and approved deployment timeline.', + 'action_items' => [ + ['task' => 'Deploy AWS staging environment templates', 'owner' => 'Engineering Team', 'deadline' => now()->addDays(2)->format('M d, Y'), 'status' => 'In Progress'], + ['task' => 'Provide SSO federation test credentials', 'owner' => 'Tech Lead', 'deadline' => now()->addDays(3)->format('M d, Y'), 'status' => 'Pending'] + ], + 'key_decisions' => [ + ['decision' => 'Approved AWS as primary cloud infrastructure provider.'], + ['decision' => 'Scheduled recurring bi-weekly progress review calls.'] + ], + 'detected_sentiment' => 'happy', + 'sentiment_breakdown' => ['happy_pct' => 85, 'neutral_pct' => 10, 'sad_pct' => 3, 'angry_pct' => 2], + 'angry_analysis' => ['is_angry' => false, 'trigger_words' => [], 'summary_why_angry' => '', 'grievances' => [], 'suggested_remedy' => ''], + 'emotion_details' => [ + 'emotion' => 'happy', + 'headline' => 'Client Delighted with Architecture Blueprint & SSO Speed', + 'bullet_points' => [ + 'Pleased with clear architecture diagram and SingleLogin SSO integration roadmap.', + 'Praised team responsiveness and transparent sprint breakdown.', + 'Approved AWS cloud hosting specs without friction.' + ], + 'trigger_quotes' => ['looks great', 'very pleased', 'excellent progress'], + 'suggested_action' => 'Maintain current sprint momentum and continue weekly demo cadence.' + ], + 'todo_items' => [ + ['id' => 1, 'task' => 'Deploy AWS staging environment templates', 'owner' => 'Engineering Team', 'priority' => 'High', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => true], + ['id' => 2, 'task' => 'Provide SSO federation test credentials', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => now()->addDays(3)->format('M d, Y'), 'completed' => false], + ['id' => 3, 'task' => 'Schedule recurring bi-weekly progress review calls', 'owner' => 'Project Manager', 'priority' => 'Low', 'deadline' => now()->addDays(5)->format('M d, Y'), 'completed' => false], + ], + 'emailed_at' => now()->subDays(3)->setTime(14, 35), + 'emailed_to' => ['client.robert@acmecorp.com', 'user@company.com', 'admin@company.com'] + ] + ); + + // 2. Client with Frustrated/Angry Sentiment + $client2 = Client::firstOrCreate( + ['client_email' => 'sarah.connor@cybertech.io'], + [ + 'client_name' => 'Sarah Connor', + 'company_name' => 'CyberTech Dynamics', + 'client_phone' => '+1 (555) 987-6543', + 'project_name' => 'AI Customer Portal & Billing Gateway', + 'project_details' => 'Customer self-service portal with integrated Stripe & PayPal billing gateway and AI chatbot.', + 'responsible_user_id' => $pm->id, + 'created_by' => $admin->id, + 'assigned_team_members' => [$pm->id], + 'status' => 'active', + 'latest_sentiment' => 'angry', + ] + ); + + $pastMeeting2 = ClientMeeting::firstOrCreate( + ['meeting_code' => 'cl-cybertech-review'], + [ + 'client_id' => $client2->id, + 'title' => 'Sprint 3 Deliverable Review & Urgent Escalation', + 'agenda' => 'Addressing payment webhook latency and unexpected sprint delays.', + 'meeting_type' => 'scheduled', + 'scheduled_at' => now()->subDay()->setTime(11, 0), + 'duration_minutes' => 35, + 'host_user_id' => $pm->id, + 'team_attendees' => [$pm->id], + 'client_emails' => ['sarah.connor@cybertech.io'], + 'status' => 'completed', + 'started_at' => now()->subDay()->setTime(11, 0), + 'ended_at' => now()->subDay()->setTime(11, 35), + ] + ); + + ClientCallNote::firstOrCreate( + ['client_meeting_id' => $pastMeeting2->id], + [ + 'client_id' => $client2->id, + 'created_by' => $pm->id, + 'raw_transcript' => "[11:02 AM] Client: We are extremely frustrated with the 2-week delay on the Stripe payment integration.\n[11:05 AM] Client: This delay is completely unacceptable and broken billing webhooks are halting our user onboarding.\n[11:15 AM] PM: We understand your concern Sarah, we will allocate two senior developers immediately to resolve the webhooks by tomorrow.", + 'live_notes' => 'Client expressed strong dissatisfaction over payment gateway delay and API latency.', + 'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** AI Customer Portal & Billing Gateway\n**Client:** Sarah Connor (CyberTech Dynamics)\n\n#### 1. Meeting Objectives\n- Address client concerns regarding Stripe payment webhook delays.\n- Realign on deployment timeline.\n\n#### 2. Key Discussion Points\n- Client raised severe blockers due to webhook latency.\n- Engineering lead committed to delivering hotfix within 24 hours.\n\n#### 3. Escalation Action Items\n- PM & Tech Lead to provide daily progress sync until resolution.", + 'summary' => 'Urgent review call where client voiced strong frustration regarding Stripe payment gateway delays and webhook issues.', + 'action_items' => [ + ['task' => 'Deliver Stripe payment webhook hotfix and test end-to-end sandbox', 'owner' => 'Tech Lead', 'deadline' => now()->addDay()->format('M d, Y'), 'status' => 'Urgent'], + ['task' => 'Send formal apology and revised sprint deliverable roadmap to client', 'owner' => 'Project Manager', 'deadline' => now()->format('M d, Y'), 'status' => 'Pending'] + ], + 'todo_items' => [ + ['id' => 1, 'task' => 'Deliver Stripe payment webhook hotfix to staging server', 'owner' => 'Tech Lead', 'priority' => 'High', 'deadline' => now()->addDay()->format('M d, Y'), 'completed' => false], + ['id' => 2, 'task' => 'Send formal roadmap update and test verification results', 'owner' => 'Project Manager', 'priority' => 'High', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => false], + ], + 'key_decisions' => [ + ['decision' => 'Assigned dedicated senior engineer to unblock billing module.'] + ], + 'detected_sentiment' => 'angry', + 'sentiment_breakdown' => ['happy_pct' => 5, 'neutral_pct' => 10, 'sad_pct' => 10, 'angry_pct' => 75], + 'angry_analysis' => [ + 'is_angry' => true, + 'trigger_words' => ['extremely frustrated', 'unacceptable', 'delay', 'broken billing webhooks', 'halting onboarding'], + 'summary_why_angry' => 'Client Sarah Connor is angry due to a 2-week delay in the Stripe payment gateway delivery and broken webhook notifications that are halting their customer onboarding operations. She emphasized that communication was lacking prior to this meeting.', + 'grievances' => [ + 'Two-week milestone delivery delay on core billing module.', + 'Unreliable webhook notifications in staging environment.', + 'Lack of proactive status updates before the sprint deadline.' + ], + 'suggested_remedy' => 'Director and PM should immediately send an executive assurance email, schedule a morning hotfix verification demo, and provide daily transparent milestone updates until billing goes live.' + ], + 'emotion_details' => [ + 'emotion' => 'angry', + 'headline' => 'Client Frustrated by 2-Week Payment Gateway Delay', + 'bullet_points' => [ + 'Two-week milestone delivery delay on core billing module.', + 'Unreliable webhook notifications in staging environment halting customer onboarding.', + 'Lack of proactive status updates before the sprint deadline.' + ], + 'trigger_quotes' => ['extremely frustrated', 'completely unacceptable', 'halting our user onboarding'], + 'suggested_action' => 'Director & PM must arrange an immediate executive check-in, assign dedicated senior engineers to unblock issues, and deliver hotfix within 24h.' + ], + 'emailed_at' => now()->subDay()->setTime(11, 40), + 'emailed_to' => ['sarah.connor@cybertech.io', 'user@company.com', 'admin@company.com'] + ] + ); + + // 3. Client with Concerned / Sad Sentiment (to test "Why was he sad / concerned?") + $client3 = Client::firstOrCreate( + ['client_email' => 'david.chen@fintechflow.com'], + [ + 'client_name' => 'David Chen', + 'company_name' => 'FinTech Flow Corp', + 'client_phone' => '+1 (555) 456-7890', + 'project_name' => 'Real-Time Financial Fraud Detection Engine', + 'project_details' => 'Machine learning based transaction fraud scoring engine with Kafka event pipelines.', + 'responsible_user_id' => $pm->id, + 'created_by' => $admin->id, + 'assigned_team_members' => [$pm->id], + 'status' => 'active', + 'latest_sentiment' => 'sad', + ] + ); + + $pastMeeting3 = ClientMeeting::firstOrCreate( + ['meeting_code' => 'cl-fintech-concern'], + [ + 'client_id' => $client3->id, + 'title' => 'QA Test Coverage & Compliance Risk Sync', + 'agenda' => 'Reviewing end-to-end testing milestones and regulatory compliance dates.', + 'meeting_type' => 'scheduled', + 'scheduled_at' => now()->setTime(10, 0), + 'duration_minutes' => 25, + 'host_user_id' => $pm->id, + 'team_attendees' => [$pm->id], + 'client_emails' => ['david.chen@fintechflow.com'], + 'status' => 'completed', + 'started_at' => now()->setTime(10, 0), + 'ended_at' => now()->setTime(10, 25), + ] + ); + + ClientCallNote::firstOrCreate( + ['client_meeting_id' => $pastMeeting3->id], + [ + 'client_id' => $client3->id, + 'created_by' => $pm->id, + 'raw_transcript' => "[10:00 AM] David: We are worried that compliance audit deadlines are in 3 weeks and test coverage is currently at 65%.\n[10:10 AM] PM: We understand your concern David, we have queued additional automated test suites.\n[10:15 AM] David: I am feeling uneasy about launching without 90% coverage.", + 'live_notes' => 'Client expressed worry and hesitation regarding regulatory compliance test coverage before launch.', + 'mom_content' => "### Minutes of Meeting (MoM)\n**Project:** Real-Time Financial Fraud Detection Engine\n**Client:** David Chen (FinTech Flow Corp)\n\n#### 1. Meeting Objectives\n- Evaluate test automation progress.\n- Align on compliance audit requirements.\n\n#### 2. Key Discussion Points\n- Client expressed concern over 65% test coverage.\n- Team agreed to prioritize automated integration tests.\n\n#### 3. Next Steps\n- Share daily test coverage metrics.", + 'summary' => 'Client expressed concern and sadness over QA test coverage timeline ahead of an upcoming regulatory compliance audit.', + 'action_items' => [ + ['task' => 'Increase unit & integration test coverage to >85%', 'owner' => 'QA Team', 'deadline' => now()->addDays(4)->format('M d, Y'), 'status' => 'In Progress'], + ['task' => 'Provide compliance audit readiness checklist', 'owner' => 'Tech Lead', 'deadline' => now()->addDays(2)->format('M d, Y'), 'status' => 'Pending'] + ], + 'todo_items' => [ + ['id' => 1, 'task' => 'Increase unit & integration test coverage to >85%', 'owner' => 'QA Team', 'priority' => 'High', 'deadline' => now()->addDays(4)->format('M d, Y'), 'completed' => false], + ['id' => 2, 'task' => 'Provide compliance audit readiness checklist to David', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => now()->addDays(2)->format('M d, Y'), 'completed' => false], + ], + 'key_decisions' => [ + ['decision' => 'Postponed non-critical UI enhancements to focus exclusively on test coverage.'] + ], + 'detected_sentiment' => 'sad', + 'sentiment_breakdown' => ['happy_pct' => 10, 'neutral_pct' => 15, 'sad_pct' => 70, 'angry_pct' => 5], + 'angry_analysis' => ['is_angry' => false, 'trigger_words' => [], 'summary_why_angry' => '', 'grievances' => [], 'suggested_remedy' => ''], + 'emotion_details' => [ + 'emotion' => 'sad', + 'headline' => 'Client Expressed Worry & Hesitation on Compliance Deadline', + 'bullet_points' => [ + 'Worried that compliance audit deadline is only 3 weeks away while test coverage is at 65%.', + 'Uneasy about potential regulatory penalties if production release has unverified edge cases.', + 'Requested daily test metrics reports and prioritized automated regression tests.' + ], + 'trigger_quotes' => ['worried about deadline', 'feeling uneasy', 'concern about test coverage'], + 'suggested_action' => 'Tech Lead and PM should proactively provide a detailed test coverage roadmap and schedule a reassuring technical walkthrough.' + ], + 'emailed_at' => now()->setTime(10, 30), + 'emailed_to' => ['david.chen@fintechflow.com', 'user@company.com', 'admin@company.com'] + ] + ); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1374a3d..48f8031 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -61,6 +61,11 @@ public function run(): void ['description' => 'Handles recruitment, payroll, and employee relations.'] ); + $directorRole = Role::firstOrCreate( + ['name' => 'Director'], + ['description' => 'Executive leadership overseeing client relations, projects, and strategy.'] + ); + // 3. Seed default apps/services (Preserve if already exists) $outlookCalendar = App::firstOrCreate( ['name' => 'Outlook Calendar'], diff --git a/public/js/client-call.js b/public/js/client-call.js new file mode 100644 index 0000000..8719f6d --- /dev/null +++ b/public/js/client-call.js @@ -0,0 +1,469 @@ +/** + * SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine + */ + +let localStream = null; +let screenStream = null; +let peerConnections = {}; +let isAudioMuted = false; +let isVideoMuted = false; +let isScreenSharing = false; + +let accumulatedTranscript = []; +let speechRecognizer = null; +let emotionTimeline = []; +let callStartTime = Date.now(); +let peerId = 'peer_' + Math.random().toString(36).substring(2, 9); +const config = window.CLIENT_CALL_CONFIG || {}; + +// Standard ICE servers +const iceServers = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' } +]; + +document.addEventListener('DOMContentLoaded', async () => { + initTimer(); + await initMedia(); + initSpeechRecognition(); + startHeartbeatLoop(); + startEmotionMonitoring(); + + window.toggleAudio = toggleAudio; + window.toggleVideo = toggleVideo; + window.toggleScreenShare = toggleScreenShare; + window.sendChatMessage = sendChatMessage; + window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM; + window.leaveCall = leaveCall; +}); + +/** + * Timer + */ +function initTimer() { + const timerEl = document.getElementById('callTimer'); + setInterval(() => { + const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000); + const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0'); + const secs = String(elapsedSec % 60).padStart(2, '0'); + if (timerEl) timerEl.innerText = `${mins}:${secs}`; + }, 1000); +} + +/** + * Initialize Local Camera & Mic Stream + */ +async function initMedia() { + try { + localStream = await navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: true + }); + const localVideo = document.getElementById('localVideo'); + if (localVideo) { + localVideo.srcObject = localStream; + } + } catch (err) { + console.warn('Camera/Mic access denied or unavailable:', err); + // Fallback: audio only or dummy stream + try { + localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (e) { + console.warn('Audio also unavailable:', e); + } + } +} + +/** + * Audio / Video Toggles + */ +function toggleAudio() { + if (!localStream) return; + const audioTrack = localStream.getAudioTracks()[0]; + if (audioTrack) { + isAudioMuted = !isAudioMuted; + audioTrack.enabled = !isAudioMuted; + const btn = document.getElementById('btnToggleMic'); + if (btn) { + btn.classList.toggle('active-off', isAudioMuted); + } + } +} + +function toggleVideo() { + if (!localStream) return; + const videoTrack = localStream.getVideoTracks()[0]; + if (videoTrack) { + isVideoMuted = !isVideoMuted; + videoTrack.enabled = !isVideoMuted; + const btn = document.getElementById('btnToggleVideo'); + if (btn) { + btn.classList.toggle('active-off', isVideoMuted); + } + } +} + +async function toggleScreenShare() { + const btn = document.getElementById('btnScreenShare'); + if (!isScreenSharing) { + try { + screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + const screenTrack = screenStream.getVideoTracks()[0]; + + // Replace video track for peers + for (let id in peerConnections) { + const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video'); + if (sender) sender.replaceTrack(screenTrack); + } + + const localVideo = document.getElementById('localVideo'); + if (localVideo) localVideo.srcObject = screenStream; + + screenTrack.onended = () => { stopScreenShare(); }; + isScreenSharing = true; + if (btn) btn.classList.add('active-on'); + } catch (e) { + console.warn('Screen share cancelled:', e); + } + } else { + stopScreenShare(); + } +} + +function stopScreenShare() { + if (screenStream) { + screenStream.getTracks().forEach(t => t.stop()); + screenStream = null; + } + if (localStream) { + const videoTrack = localStream.getVideoTracks()[0]; + for (let id in peerConnections) { + const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video'); + if (sender && videoTrack) sender.replaceTrack(videoTrack); + } + const localVideo = document.getElementById('localVideo'); + if (localVideo) localVideo.srcObject = localStream; + } + isScreenSharing = false; + const btn = document.getElementById('btnScreenShare'); + if (btn) btn.classList.remove('active-on'); +} + +/** + * Web Speech Recognition for Live Transcription & AI Note Taking + */ +function initSpeechRecognition() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + const feed = document.getElementById('transcriptFeed'); + const statusEl = document.getElementById('sttStatus'); + + if (!SpeechRecognition) { + if (statusEl) statusEl.innerText = '● Speech API (Manual)'; + return; + } + + try { + speechRecognizer = new SpeechRecognition(); + speechRecognizer.continuous = true; + speechRecognizer.interimResults = true; + speechRecognizer.lang = 'en-US'; + + speechRecognizer.onresult = (event) => { + for (let i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + const text = event.results[i][0].transcript.trim(); + if (text.length > 0) { + const line = { + speaker: config.participantName || 'Speaker', + text: text, + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + }; + accumulatedTranscript.push(line); + appendTranscriptLine(line); + + // Check emotion on spoken text + checkEmotionOnText(text); + } + } + } + }; + + speechRecognizer.onerror = (e) => { + console.warn('Speech Recognition error:', e.error); + }; + + speechRecognizer.onend = () => { + // Auto restart recognition + try { + speechRecognizer.start(); + } catch (err) {} + }; + + speechRecognizer.start(); + } catch (e) { + console.warn('Could not initialize Speech Recognition:', e); + } +} + +function appendTranscriptLine(line) { + const feed = document.getElementById('transcriptFeed'); + if (!feed) return; + + const div = document.createElement('div'); + div.className = 'transcript-line'; + div.innerHTML = `${escapeHtml(line.speaker)} (${line.time}): ${escapeHtml(line.text)}`; + feed.appendChild(div); + feed.scrollTop = feed.scrollHeight; +} + +/** + * Live Emotion & Expression Monitoring + */ +function startEmotionMonitoring() { + // Check face emotion snapshot every 10 seconds + setInterval(() => { + captureAndAnalyzeFrame(); + }, 10000); +} + +function captureAndAnalyzeFrame() { + const video = document.getElementById('localVideo'); + const canvas = document.getElementById('emotionCanvas'); + if (!video || !canvas || !video.videoWidth) return; + + canvas.width = 320; + canvas.height = 240; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + const frameBase64 = canvas.toDataURL('image/jpeg', 0.6); + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + fetch(config.detectEmotionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + image_frame: frameBase64, + speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ') + }) + }) + .then(res => res.json()) + .then(data => { + if (data && data.emotion) { + updateEmotionBadge(data.emotion, data.label); + emotionTimeline.push({ + emotion: data.emotion, + time: Date.now() + }); + } + }) + .catch(() => {}); +} + +function checkEmotionOnText(text) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + fetch(config.detectEmotionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ speech_text: text }) + }) + .then(res => res.json()) + .then(data => { + if (data && data.emotion) { + updateEmotionBadge(data.emotion, data.label); + emotionTimeline.push({ + emotion: data.emotion, + time: Date.now() + }); + } + }) + .catch(() => {}); +} + +function updateEmotionBadge(emotion, label) { + const badge = document.getElementById('liveEmotionBadge'); + const emojiEl = document.getElementById('emotionEmoji'); + const textEl = document.getElementById('emotionText'); + if (!badge || !emojiEl || !textEl) return; + + badge.className = 'emotion-live-indicator'; + + if (emotion === 'happy') { + badge.classList.add('emotion-badge-happy'); + emojiEl.innerText = '😊'; + textEl.innerText = label || 'Client Mood: Happy / Satisfied'; + } else if (emotion === 'angry') { + badge.classList.add('emotion-badge-angry'); + emojiEl.innerText = '😡'; + textEl.innerText = label || 'Client Mood: Frustrated / Angry'; + } else if (emotion === 'sad') { + badge.classList.add('emotion-badge-sad'); + emojiEl.innerText = '😟'; + textEl.innerText = label || 'Client Mood: Concerned / Sad'; + } else { + badge.classList.add('emotion-badge-neutral'); + emojiEl.innerText = '😐'; + textEl.innerText = label || 'Client Mood: Neutral / Attentive'; + } +} + +/** + * WebRTC Signaling & Chat Loop + */ +let outgoingChatMessage = null; + +function sendChatMessage() { + const input = document.getElementById('chatInput'); + const text = input.value.trim(); + if (!text) return; + + outgoingChatMessage = { text: text }; + input.value = ''; +} + +function startHeartbeatLoop() { + setInterval(async () => { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + try { + const res = await fetch(config.heartbeatUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + peer_id: peerId, + peer_name: config.participantName, + chat_message: outgoingChatMessage + }) + }); + + outgoingChatMessage = null; + const data = await res.json(); + + // Update chat feed + if (data.chat_messages) { + renderChat(data.chat_messages); + } + + // Manage remote peers + if (data.active_peers) { + handleActivePeers(data.active_peers); + } + } catch (e) { + console.debug('Heartbeat error:', e); + } + }, 2500); +} + +function renderChat(messages) { + const feed = document.getElementById('chatFeed'); + if (!feed) return; + feed.innerHTML = ''; + messages.forEach(m => { + const div = document.createElement('div'); + div.className = 'chat-msg'; + div.innerHTML = `
${escapeHtml(m.sender)} • ${m.time || ''}
${escapeHtml(m.text)}
`; + feed.appendChild(div); + }); +} + +function handleActivePeers(peers) { + const grid = document.getElementById('videoGrid'); + peers.forEach(peer => { + if (peer.peer_id === peerId) return; + + let tile = document.getElementById('tile_' + peer.peer_id); + if (!tile) { + tile = document.createElement('div'); + tile.id = 'tile_' + peer.peer_id; + tile.className = 'video-tile'; + tile.innerHTML = ` +
${peer.peer_name.charAt(0).toUpperCase()}
+
+ ${escapeHtml(peer.peer_name)} +
+ `; + grid.appendChild(tile); + } + }); + + // Remove left peers + const activeIds = peers.map(p => 'tile_' + p.peer_id); + document.querySelectorAll('.video-tile').forEach(tile => { + if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) { + tile.remove(); + } + }); +} + +/** + * End Call & MoM Generation + */ +async function endMeetingAndGenerateMoM() { + if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) { + return; + } + + const liveNotes = document.getElementById('liveNotesInput')?.value || ''; + const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + // Show processing indicator + const btn = document.querySelector('.btn-end-call'); + if (btn) { + btn.disabled = true; + btn.innerText = 'Generating AI MoM & Dispatching Emails...'; + } + + try { + const res = await fetch(config.endCallUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + transcript: transcriptText, + live_notes: liveNotes, + emotion_timeline: emotionTimeline + }) + }); + + const data = await res.json(); + if (data.redirect_url) { + window.location.href = data.redirect_url; + } else { + window.location.href = config.redirectUrl; + } + } catch (e) { + alert('Meeting ended. Redirecting to Client Hub...'); + window.location.href = config.redirectUrl; + } +} + +function leaveCall() { + if (confirm('Leave this meeting?')) { + window.location.href = config.redirectUrl || '/'; + } +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>"']/g, function(m) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; + }); +} diff --git a/resources/js/client-call.js b/resources/js/client-call.js new file mode 100644 index 0000000..96e1e5b --- /dev/null +++ b/resources/js/client-call.js @@ -0,0 +1,459 @@ +/** + * SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine + */ + +let localStream = null; +let screenStream = null; +let peerConnections = {}; +let isAudioMuted = false; +let isVideoMuted = false; +let isScreenSharing = false; + +let accumulatedTranscript = []; +let speechRecognizer = null; +let emotionTimeline = []; +let callStartTime = Date.now(); +let peerId = 'peer_' + Math.random().toString(36).substring(2, 9); +const config = window.CLIENT_CALL_CONFIG || {}; + +// Standard ICE servers +const iceServers = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' } +]; + +document.addEventListener('DOMContentLoaded', async () => { + initTimer(); + await initMedia(); + initSpeechRecognition(); + startHeartbeatLoop(); + startEmotionMonitoring(); + + window.toggleAudio = toggleAudio; + window.toggleVideo = toggleVideo; + window.toggleScreenShare = toggleScreenShare; + window.sendChatMessage = sendChatMessage; + window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM; + window.leaveCall = leaveCall; +}); + +/** + * Timer + */ +function initTimer() { + const timerEl = document.getElementById('callTimer'); + setInterval(() => { + const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000); + const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0'); + const secs = String(elapsedSec % 60).padStart(2, '0'); + if (timerEl) timerEl.innerText = `${mins}:${secs}`; + }, 1000); +} + +/** + * Initialize Local Camera & Mic Stream + */ +async function initMedia() { + try { + localStream = await navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: true + }); + const localVideo = document.getElementById('localVideo'); + if (localVideo) { + localVideo.srcObject = localStream; + } + } catch (err) { + console.warn('Camera/Mic access denied or unavailable:', err); + try { + localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (e) { + console.warn('Audio also unavailable:', e); + } + } +} + +/** + * Audio / Video Toggles + */ +function toggleAudio() { + if (!localStream) return; + const audioTrack = localStream.getAudioTracks()[0]; + if (audioTrack) { + isAudioMuted = !isAudioMuted; + audioTrack.enabled = !isAudioMuted; + const btn = document.getElementById('btnToggleMic'); + if (btn) { + btn.classList.toggle('active-off', isAudioMuted); + } + } +} + +function toggleVideo() { + if (!localStream) return; + const videoTrack = localStream.getVideoTracks()[0]; + if (videoTrack) { + isVideoMuted = !isVideoMuted; + videoTrack.enabled = !isVideoMuted; + const btn = document.getElementById('btnToggleVideo'); + if (btn) { + btn.classList.toggle('active-off', isVideoMuted); + } + } +} + +async function toggleScreenShare() { + const btn = document.getElementById('btnScreenShare'); + if (!isScreenSharing) { + try { + screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + const screenTrack = screenStream.getVideoTracks()[0]; + + for (let id in peerConnections) { + const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video'); + if (sender) sender.replaceTrack(screenTrack); + } + + const localVideo = document.getElementById('localVideo'); + if (localVideo) localVideo.srcObject = screenStream; + + screenTrack.onended = () => { stopScreenShare(); }; + isScreenSharing = true; + if (btn) btn.classList.add('active-on'); + } catch (e) { + console.warn('Screen share cancelled:', e); + } + } else { + stopScreenShare(); + } +} + +function stopScreenShare() { + if (screenStream) { + screenStream.getTracks().forEach(t => t.stop()); + screenStream = null; + } + if (localStream) { + const videoTrack = localStream.getVideoTracks()[0]; + for (let id in peerConnections) { + const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video'); + if (sender && videoTrack) sender.replaceTrack(videoTrack); + } + const localVideo = document.getElementById('localVideo'); + if (localVideo) localVideo.srcObject = localStream; + } + isScreenSharing = false; + const btn = document.getElementById('btnScreenShare'); + if (btn) btn.classList.remove('active-on'); +} + +/** + * Web Speech Recognition for Live Transcription & AI Note Taking + */ +function initSpeechRecognition() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + const feed = document.getElementById('transcriptFeed'); + const statusEl = document.getElementById('sttStatus'); + + if (!SpeechRecognition) { + if (statusEl) statusEl.innerText = '● Speech API (Manual)'; + return; + } + + try { + speechRecognizer = new SpeechRecognition(); + speechRecognizer.continuous = true; + speechRecognizer.interimResults = true; + speechRecognizer.lang = 'en-US'; + + speechRecognizer.onresult = (event) => { + for (let i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + const text = event.results[i][0].transcript.trim(); + if (text.length > 0) { + const line = { + speaker: config.participantName || 'Speaker', + text: text, + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + }; + accumulatedTranscript.push(line); + appendTranscriptLine(line); + + checkEmotionOnText(text); + } + } + } + }; + + speechRecognizer.onerror = (e) => { + console.warn('Speech Recognition error:', e.error); + }; + + speechRecognizer.onend = () => { + try { + speechRecognizer.start(); + } catch (err) {} + }; + + speechRecognizer.start(); + } catch (e) { + console.warn('Could not initialize Speech Recognition:', e); + } +} + +function appendTranscriptLine(line) { + const feed = document.getElementById('transcriptFeed'); + if (!feed) return; + + const div = document.createElement('div'); + div.className = 'transcript-line'; + div.innerHTML = `${escapeHtml(line.speaker)} (${line.time}): ${escapeHtml(line.text)}`; + feed.appendChild(div); + feed.scrollTop = feed.scrollHeight; +} + +/** + * Live Emotion & Expression Monitoring + */ +function startEmotionMonitoring() { + setInterval(() => { + captureAndAnalyzeFrame(); + }, 10000); +} + +function captureAndAnalyzeFrame() { + const video = document.getElementById('localVideo'); + const canvas = document.getElementById('emotionCanvas'); + if (!video || !canvas || !video.videoWidth) return; + + canvas.width = 320; + canvas.height = 240; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + const frameBase64 = canvas.toDataURL('image/jpeg', 0.6); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + fetch(config.detectEmotionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + image_frame: frameBase64, + speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ') + }) + }) + .then(res => res.json()) + .then(data => { + if (data && data.emotion) { + updateEmotionBadge(data.emotion, data.label); + emotionTimeline.push({ + emotion: data.emotion, + time: Date.now() + }); + } + }) + .catch(() => {}); +} + +function checkEmotionOnText(text) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + fetch(config.detectEmotionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ speech_text: text }) + }) + .then(res => res.json()) + .then(data => { + if (data && data.emotion) { + updateEmotionBadge(data.emotion, data.label); + emotionTimeline.push({ + emotion: data.emotion, + time: Date.now() + }); + } + }) + .catch(() => {}); +} + +function updateEmotionBadge(emotion, label) { + const badge = document.getElementById('liveEmotionBadge'); + const emojiEl = document.getElementById('emotionEmoji'); + const textEl = document.getElementById('emotionText'); + if (!badge || !emojiEl || !textEl) return; + + badge.className = 'emotion-live-indicator'; + + if (emotion === 'happy') { + badge.classList.add('emotion-badge-happy'); + emojiEl.innerText = '😊'; + textEl.innerText = label || 'Client Mood: Happy / Satisfied'; + } else if (emotion === 'angry') { + badge.classList.add('emotion-badge-angry'); + emojiEl.innerText = '😡'; + textEl.innerText = label || 'Client Mood: Frustrated / Angry'; + } else if (emotion === 'sad') { + badge.classList.add('emotion-badge-sad'); + emojiEl.innerText = '😟'; + textEl.innerText = label || 'Client Mood: Concerned / Sad'; + } else { + badge.classList.add('emotion-badge-neutral'); + emojiEl.innerText = '😐'; + textEl.innerText = label || 'Client Mood: Neutral / Attentive'; + } +} + +/** + * WebRTC Signaling & Chat Loop + */ +let outgoingChatMessage = null; + +function sendChatMessage() { + const input = document.getElementById('chatInput'); + const text = input.value.trim(); + if (!text) return; + + outgoingChatMessage = { text: text }; + input.value = ''; +} + +function startHeartbeatLoop() { + setInterval(async () => { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + try { + const res = await fetch(config.heartbeatUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + peer_id: peerId, + peer_name: config.participantName, + chat_message: outgoingChatMessage + }) + }); + + outgoingChatMessage = null; + const data = await res.json(); + + if (data.chat_messages) { + renderChat(data.chat_messages); + } + + if (data.active_peers) { + handleActivePeers(data.active_peers); + } + } catch (e) { + console.debug('Heartbeat error:', e); + } + }, 2500); +} + +function renderChat(messages) { + const feed = document.getElementById('chatFeed'); + if (!feed) return; + feed.innerHTML = ''; + messages.forEach(m => { + const div = document.createElement('div'); + div.className = 'chat-msg'; + div.innerHTML = `
${escapeHtml(m.sender)} • ${m.time || ''}
${escapeHtml(m.text)}
`; + feed.appendChild(div); + }); +} + +function handleActivePeers(peers) { + const grid = document.getElementById('videoGrid'); + peers.forEach(peer => { + if (peer.peer_id === peerId) return; + + let tile = document.getElementById('tile_' + peer.peer_id); + if (!tile) { + tile = document.createElement('div'); + tile.id = 'tile_' + peer.peer_id; + tile.className = 'video-tile'; + tile.innerHTML = ` +
${peer.peer_name.charAt(0).toUpperCase()}
+
+ ${escapeHtml(peer.peer_name)} +
+ `; + grid.appendChild(tile); + } + }); + + const activeIds = peers.map(p => 'tile_' + p.peer_id); + document.querySelectorAll('.video-tile').forEach(tile => { + if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) { + tile.remove(); + } + }); +} + +/** + * End Call & MoM Generation + */ +async function endMeetingAndGenerateMoM() { + if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) { + return; + } + + const liveNotes = document.getElementById('liveNotesInput')?.value || ''; + const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + + const btn = document.querySelector('.btn-end-call'); + if (btn) { + btn.disabled = true; + btn.innerText = 'Generating AI MoM & Dispatching Emails...'; + } + + try { + const res = await fetch(config.endCallUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + transcript: transcriptText, + live_notes: liveNotes, + emotion_timeline: emotionTimeline + }) + }); + + const data = await res.json(); + if (data.redirect_url) { + window.location.href = data.redirect_url; + } else { + window.location.href = config.redirectUrl; + } + } catch (e) { + alert('Meeting ended. Redirecting to Client Hub...'); + window.location.href = config.redirectUrl; + } +} + +function leaveCall() { + if (confirm('Leave this meeting?')) { + window.location.href = config.redirectUrl || '/'; + } +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>"']/g, function(m) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; + }); +} diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index c0fdc82..3d2428b 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -816,7 +816,10 @@
🏷️
+ +
+
+
Client Projects
+
{{ $stats['clients_count'] ?? 0 }}
+
+
📁
+
+ +
+
+
Client Alerts
+
{{ $stats['angry_clients_count'] ?? 0 }}
+
+
{{ ($stats['angry_clients_count'] ?? 0) > 0 ? '⚠️' : '✨' }}
+
@@ -915,6 +934,96 @@ + +
+ + +
+ + + + + + + + + + + + @forelse($clients as $cl) + @php + $sent = strtolower($cl->latest_sentiment ?? 'neutral'); + $badgeStyle = match($sent) { + 'happy' => 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.35);', + 'sad' => 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.35);', + 'angry' => 'background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.45); font-weight: 700;', + default => 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.35);', + }; + $emoji = match($sent) { + 'happy' => '😊 Happy', + 'sad' => '😟 Concerned', + 'angry' => '😡 Frustrated', + default => '😐 Neutral', + }; + $latestNote = $cl->callNotes->first(); + @endphp + + + + + + + + @empty + + + + @endforelse + +
Project & ClientResponsible LeadTotal CallsClient ExpressionAction
+
{{ $cl->project_name }}
+
+ 👤 {{ $cl->client_name }} + @if($cl->company_name)• {{ $cl->company_name }}@endif + • ✉️ {{ $cl->client_email }} +
+
+ + {{ $cl->responsibleUser->name ?? 'Unassigned' }} + + + {{ $cl->meetings->count() }} meetings + + + {{ $emoji }} + + @if($sent === 'angry' && $latestNote) + + @endif + + + Open Client Hub ↗ + +
+ No client projects registered yet. Create first client project +
+
+
+
@@ -1779,8 +1888,95 @@ function confirmAdminToggle(userId, userName, actionType) { } }); } + + async function openAdminWhyAngryModal(noteId) { + const modal = document.getElementById('adminWhyAngryModal'); + const content = document.getElementById('adminWhyAngryContent'); + modal.classList.add('active'); + content.innerHTML = '
Analyzing transcript and emotion triggers...
'; + + try { + const res = await fetch(`/client-calls/notes/${noteId}/angry-analysis`, { + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } + }); + const data = await res.json(); + + if (!res.ok) { + content.innerHTML = `
${data.error || 'Access Denied.'}
`; + return; + } + + const analysis = data.angry_analysis || {}; + const triggers = analysis.trigger_words || []; + const grievances = analysis.grievances || []; + + content.innerHTML = ` +
+
Client & Project
+
${data.client_name} • ${data.project_name}
+
+ +
+
AI Executive Breakdown
+

${analysis.summary_why_angry || 'Client showed frustration during the meeting.'}

+
+ + ${triggers.length > 0 ? ` +
+
Identified Frustrated / Angry Keywords:
+
+ ${triggers.map(t => `"${t}"`).join('')} +
+
+ ` : ''} + + ${grievances.length > 0 ? ` +
+
Key Complaints / Pain Points:
+
    + ${grievances.map(g => `
  • ${g}
  • `).join('')} +
+
+ ` : ''} + + ${analysis.suggested_remedy ? ` +
+
💡 Recommended Action for PM & Director:
+

${analysis.suggested_remedy}

+
+ ` : ''} + `; + } catch (err) { + content.innerHTML = `
Failed to load analysis.
`; + } + } + + function closeAdminWhyAngryModal() { + document.getElementById('adminWhyAngryModal').classList.remove('active'); + } + +
+
+ +
+ +
+ +
+
+
© 2026 Systems. Authorized Access Only.
diff --git a/resources/views/client_calls/guest_join.blade.php b/resources/views/client_calls/guest_join.blade.php new file mode 100644 index 0000000..55279b0 --- /dev/null +++ b/resources/views/client_calls/guest_join.blade.php @@ -0,0 +1,157 @@ + + + + + + Join Meeting | SingleLogin Connect + + + + + + + + + +
+
+ + SingleLogin Video Connect +
+ +

{{ $meeting->title }}

+

{{ $meeting->client->project_name }} • Hosted by {{ $meeting->host->name ?? 'SingleLogin Host' }}

+ +
+ @csrf +
+ + +
+ +
+ + +
+ + +
+ + +
+ + + diff --git a/resources/views/client_calls/index.blade.php b/resources/views/client_calls/index.blade.php new file mode 100644 index 0000000..b79ea36 --- /dev/null +++ b/resources/views/client_calls/index.blade.php @@ -0,0 +1,937 @@ + + + + + + Client Calls & Projects Hub | SingleLogin + + + + + + + + + + + + + + + +
+ + @if(session('success')) +
+ {{ session('success') }} + +
+ @endif + + +
+
+
📁
+
+
{{ $totalClients }}
+
Active Client Projects
+
+
+
+
📞
+
+
{{ $totalMeetings }}
+
Total Meetings Held
+
+
+
+
+ {{ $angryClientsCount > 0 ? '⚠️' : '✨' }} +
+
+
+ {{ $angryClientsCount }} +
+
Client Attention Alerts
+
+
+
+ + +
+
+

Client Projects & Meeting Hub

+

Manage clients, schedule future calls, start instant meetings with AI transcription, and review automated MoMs.

+
+
+ + +
+ + +
+ + + @if($clients->isEmpty()) +
+
💼
+

No Client Projects Found

+

Get started by adding your first client and project details.

+ +
+ @else +
+ @foreach($clients as $client) + @php + $sentiment = strtolower($client->latest_sentiment ?? 'neutral'); + $badgeClass = match($sentiment) { + 'happy' => 'sentiment-happy', + 'sad' => 'sentiment-sad', + 'angry' => 'sentiment-angry', + default => 'sentiment-neutral', + }; + $sentimentEmoji = match($sentiment) { + 'happy' => '😊 Happy', + 'sad' => '😟 Concerned', + 'angry' => '😡 Frustrated', + default => '😐 Neutral', + }; + + $meetingsCount = $client->meetings->count(); + $latestNote = $client->callNotes->first(); + $canViewAngry = $client->canViewAngryAnalysis(Auth::user()); + @endphp + +
+ +
+
+
+
{{ $client->project_name }}
+
+ + {{ $client->client_name }} {{ $client->company_name ? "• " . $client->company_name : "" }} +
+
+ + {{ $sentimentEmoji }} + +
+ +
+
+ Client Email: + {{ $client->client_email }} +
+
+ Responsible Lead: + {{ $client->responsibleUser->name ?? 'Unassigned' }} +
+
+ Total Meetings: + {{ $meetingsCount }} calls +
+ @if($latestNote) +
+ Last Call Date: + {{ $latestNote->created_at->format('M d, Y') }} +
+ @endif +
+ + + @if($sentiment === 'angry' && $latestNote && $canViewAngry) +
+ ⚠️ Client Dissatisfaction Detected + +
+ @endif +
+ + +
+ @endforeach +
+ @endif + +
+ + + + + + + + + + diff --git a/resources/views/client_calls/logs.blade.php b/resources/views/client_calls/logs.blade.php new file mode 100644 index 0000000..be970f9 --- /dev/null +++ b/resources/views/client_calls/logs.blade.php @@ -0,0 +1,1010 @@ + + + + + + + Date-wise Call Logs & Meeting Intelligence | SingleLogin + + + + + + + + + + + + + + + +
+ + +
+
+
📞
+
+
{{ $totalCalls }}
+
Total Calls Logged
+
+
+
+
😊
+
+
{{ $happyCalls }}
+
Satisfied / Happy
+
+
+
+
😟
+
+
{{ $sadCalls }}
+
Concerned / Sad
+
+
+
+
😡
+
+
{{ $angryCalls }}
+
Frustrated / Angry
+
+
+
+
📋
+
+
{{ $totalTodosCount }}
+
Actionable To-Dos
+
+
+
+ + +
+
+

Date-wise Client Call Logs

+

+ Review date-wise meeting transcripts, click client expressions to inspect emotional drivers, and export MoMs & To-Do lists in .txt format. +

+
+
+ + +
+ + + +
+ + + @if($groupedNotes->isEmpty()) +
+
📞
+

No Call Logs Recorded Yet

+

Once client calls are held, full AI transcription, MoM, and To-Do lists will be logged here date-wise.

+
+ @else + @foreach($groupedNotes as $dateKey => $notes) +
+
+ 📅 {{ \Carbon\Carbon::parse($dateKey)->format('F j, Y') }} + {{ $notes->count() }} {{ $notes->count() === 1 ? 'call' : 'calls' }} + +
+ + @foreach($notes as $note) + @php + $sentiment = strtolower($note->detected_sentiment ?? 'neutral'); + $badgeClass = match($sentiment) { + 'happy' => 'emotion-happy', + 'sad' => 'emotion-sad', + 'angry' => 'emotion-angry', + default => 'emotion-neutral', + }; + $sentimentEmoji = match($sentiment) { + 'happy' => '😊 Happy', + 'sad' => '😟 Concerned', + 'angry' => '😡 Frustrated', + default => '😐 Neutral', + }; + $todoCount = count($note->todo_items ?? $note->action_items ?? []); + @endphp + +
+ +
+
+
+ {{ $note->meeting->title ?? 'Client Call' }} + + {{ $note->client->project_name ?? 'Project' }} + +
+
+ 👤 {{ $note->client->client_name ?? 'Client' }} {{ $note->client->company_name ? "({$note->client->company_name})" : "" }} + 🕒 {{ $note->created_at ? $note->created_at->format('h:i A') : '' }} + 🎙️ Host: {{ $note->creator->name ?? 'SingleLogin Host' }} +
+
+ + + +
+ + +
+ {{ $note->summary ?? 'Meeting concluded with milestone alignment.' }} +
+ + +
+
+ + + 📥 Download MoM (.txt) + + + + + + + + 📄 To-Do (.txt) + +
+ +
+ + + Open Client Hub ↗ + +
+
+
+ @endforeach +
+ @endforeach + @endif + +
+ + + + + + + + + + + + + diff --git a/resources/views/client_calls/room.blade.php b/resources/views/client_calls/room.blade.php new file mode 100644 index 0000000..d730574 --- /dev/null +++ b/resources/views/client_calls/room.blade.php @@ -0,0 +1,575 @@ + + + + + + + {{ $meeting->title }} | Live Client Call Room + + + + + + + + + + +
+
+ LIVE +
{{ $meeting->title }}
+
00:00
+
+ +
+ +
+ 😐 + Client Emotion: Neutral +
+ + +
+
+ + +
+ +
+
+ +
+ +
+ {{ $participantName }} (You) +
+
+
+
+ + +
+
+
+ AI Meeting Assistant +
+ +
+ +
+ + +
+ + +
+
+
+ Real-Time Speech Transcription + ● Listening +
+
+
Transcribing live spoken speech...
+
+
+ +
+
Live Meeting Notes
+ +
+ + @if($isHost) + + @endif +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + @if($isHost) + + @else + + @endif +
+ + + + + +
Link copied to clipboard!
+ + + + + + + + + + diff --git a/resources/views/client_calls/show.blade.php b/resources/views/client_calls/show.blade.php new file mode 100644 index 0000000..a7fec40 --- /dev/null +++ b/resources/views/client_calls/show.blade.php @@ -0,0 +1,1244 @@ + + + + + + {{ $client->project_name }} | Client Call Hub | SingleLogin + + + + + + + + + + + + + + + +
+ + @if(session('success')) +
+ {{ session('success') }} + +
+ @endif + + +
+
+
+ Client Profile & Project Space +
+

{{ $client->project_name }}

+ +
+
+ + {{ $client->client_name }} {{ $client->company_name ? "({$client->company_name})" : "" }} +
+
+ + {{ $client->client_email }} +
+ @if($client->client_phone) +
+ + {{ $client->client_phone }} +
+ @endif +
+ Lead in Charge: + {{ $client->responsibleUser->name ?? 'Unassigned' }} +
+
+
+ +
+ @php + $sentiment = strtolower($client->latest_sentiment ?? 'neutral'); + $sentimentClass = match($sentiment) { + 'happy' => 'sentiment-happy', + 'sad' => 'sentiment-sad', + 'angry' => 'sentiment-angry', + default => 'sentiment-neutral', + }; + $sentimentEmoji = match($sentiment) { + 'happy' => '😊 Happy / Satisfied', + 'sad' => '😟 Concerned / Sad', + 'angry' => '😡 Frustrated / Angry', + default => '😐 Neutral / Professional', + }; + @endphp +
+
Latest Client Mood
+ {{ $sentimentEmoji }} +
+ +
+ + +
+
+
+ + + @php + $latestNote = $client->callNotes->first(); + @endphp + @if($sentiment === 'angry' && $latestNote && $canViewAngry) +
+
+
⚠️
+
+
Managerial Escalation: Client Frustration Detected in Recent Call
+
AI has isolated critical dissatisfaction triggers from the speech transcript. Accessible only to Admin & Responsible Lead.
+
+
+ +
+ @endif + + +
+ + + +
+ + +
+ @if($client->meetings->isEmpty()) +
+
📅
+

No Meetings Scheduled Yet

+

Create an instant meeting or schedule a future call with invitation emails.

+ +
+ @else +
+ @foreach($client->meetings as $meeting) + @php + $isUpcoming = ($meeting->scheduled_at && $meeting->scheduled_at->isFuture()) || $meeting->status === 'scheduled'; + @endphp +
+
+
+ + {{ $meeting->meeting_type === 'instant' ? '⚡ Instant' : '📅 Scheduled' }} + + + • {{ $meeting->status }} + +
+ +
{{ $meeting->title }}
+ +
+ @if($meeting->scheduled_at) +
🕒 {{ $meeting->scheduled_at->format('M d, Y - h:i A') }} ({{ $meeting->duration_minutes }}m)
+ @else +
🕒 Created: {{ $meeting->created_at->format('M d, Y - h:i A') }}
+ @endif +
👤 Host: {{ $meeting->host->name ?? 'SingleLogin Host' }}
+ @if(!empty($meeting->client_emails)) +
✉️ Client Invites: {{ count($meeting->client_emails) }}
+ @endif +
+ + @if($meeting->agenda) +
+ Agenda: {{ $meeting->agenda }} +
+ @endif +
+ +
+ + + 👉 Join Room + +
+
+ @endforeach +
+ @endif +
+ + +
+ @if($client->callNotes->isEmpty()) +
+
📝
+

No Past Call Notes or MoMs Yet

+

When you complete a client call, AI will automatically take notes, detect client emotion, generate MoM, and archive it here.

+
+ @else + @foreach($client->callNotes as $note) + @php + $noteSentiment = strtolower($note->detected_sentiment ?? 'neutral'); + $noteBadgeClass = match($noteSentiment) { + 'happy' => 'sentiment-happy', + 'sad' => 'sentiment-sad', + 'angry' => 'sentiment-angry', + default => 'sentiment-neutral', + }; + $noteEmoji = match($noteSentiment) { + 'happy' => '😊 Happy', + 'sad' => '😟 Concerned', + 'angry' => '😡 Frustrated / Angry', + default => '😐 Neutral', + }; + @endphp +
+
+
+
{{ $note->meeting->title ?? 'Client Call Session' }}
+
+ Recorded on {{ $note->created_at->format('F j, Y - h:i A') }} • Note Host: {{ $note->creator->name ?? 'SingleLogin AI' }} + @if($note->emailed_at) + • ✉️ MoM Emailed to TL, Director, PM ({{ $note->emailed_at->format('M d, h:i A') }}) + @endif +
+
+
+ + + + 📥 MoM (.txt) + + + + 📋 To-Dos (.txt) + + + @if($noteSentiment === 'angry' && $canViewAngry) + + @endif + + +
+
+ + +
Executive Summary
+
+ {{ $note->summary ?? 'Summary unavailable.' }} +
+ + + @if(!empty($note->todo_items) && count($note->todo_items) > 0) +
+ 📋 Auto-Generated Actionable To-Dos ({{ count($note->todo_items) }}) + Download .txt +
+
+ @foreach($note->todo_items as $todo) +
+ +
+
+ {{ $todo['task'] ?? '' }} +
+
+ 👤 {{ $todo['owner'] ?? 'Assigned Lead' }} + ⚡ Priority: {{ $todo['priority'] ?? 'Medium' }} + 📅 Due: {{ $todo['deadline'] ?? 'TBD' }} +
+
+
+ @endforeach +
+ @endif + + + @if(!empty($note->action_items) && count($note->action_items) > 0) +
Action Items & Deliverables
+ + + + + + + + + + + @foreach($note->action_items as $act) + + + + + + + @endforeach + +
TaskOwnerDeadlineStatus
{{ is_array($act) ? ($act['task'] ?? json_encode($act)) : $act }}{{ is_array($act) ? ($act['owner'] ?? 'Lead') : 'Lead' }}{{ is_array($act) ? ($act['deadline'] ?? '-') : '-' }}{{ is_array($act) ? ($act['status'] ?? 'Pending') : 'Pending' }}
+ @endif + + + @if(!empty($note->key_decisions) && count($note->key_decisions) > 0) +
Key Decisions Made
+
    + @foreach($note->key_decisions as $dec) +
  • {{ is_array($dec) ? ($dec['decision'] ?? json_encode($dec)) : $dec }}
  • + @endforeach +
+ @endif + + +
+ + View Complete Structured MoM Document + +
{!! nl2br(e($note->mom_content)) !!}
+
+ + + @if($note->raw_transcript) +
+ + View Raw Call Speech Transcript + +
{!! nl2br(e($note->raw_transcript)) !!}
+
+ @endif +
+ @endforeach + @endif +
+ + +
+
+

Project Scope & Specifications

+
+ {{ $client->project_details ?: 'No additional project specifications provided.' }} +
+ +

Assigned Internal Project Team

+
+
+
Responsible Project Lead
+
{{ $client->responsibleUser->name ?? 'Unassigned' }}
+
{{ $client->responsibleUser->email ?? '' }}
+
+ + @foreach($client->getTeamMembers() as $member) +
+
Team Member
+
{{ $member->name }}
+
{{ $member->email }}
+
+ @endforeach +
+
+
+ +
+ + + + + + + + + + + + + + + + diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index e0132cf..1e7197f 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -879,6 +879,12 @@ @endif + @if($user->canAccessClientCalls()) + + 📞 Client Calls + + @endif + @if($user->canManageOnboarding()) 🚀 Onboarding Hub diff --git a/resources/views/emails/call_mom.blade.php b/resources/views/emails/call_mom.blade.php new file mode 100644 index 0000000..d884d28 --- /dev/null +++ b/resources/views/emails/call_mom.blade.php @@ -0,0 +1,113 @@ + + + + + + Minutes of Meeting (MoM) + + + + + + diff --git a/resources/views/emails/meeting_invite.blade.php b/resources/views/emails/meeting_invite.blade.php new file mode 100644 index 0000000..dd9b8f6 --- /dev/null +++ b/resources/views/emails/meeting_invite.blade.php @@ -0,0 +1,84 @@ + + + + + + Meeting Invitation + + + + + + diff --git a/routes/web.php b/routes/web.php index cd4fcd9..364d43e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('interview.download-recording'); Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status'); Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot'); + + // Client Call & Project Management Hub (PM, TL, Director, Admin) + Route::get('/client-calls', [ClientCallController::class, 'index'])->name('client-calls.index'); + Route::get('/client-calls/logs', [ClientCallController::class, 'callLogs'])->name('client-calls.logs'); + Route::post('/client-calls', [ClientCallController::class, 'store'])->name('client-calls.store'); + Route::get('/client-calls/{id}', [ClientCallController::class, 'show'])->name('client-calls.show'); + Route::post('/client-calls/{id}/schedule-meeting', [ClientCallController::class, 'scheduleMeeting'])->name('client-calls.schedule-meeting'); + Route::post('/client-calls/{id}/instant-meeting', [ClientCallController::class, 'createInstantMeeting'])->name('client-calls.instant-meeting'); + Route::get('/client-calls/notes/{id}/download-mom', [ClientCallController::class, 'downloadMoM'])->name('client-calls.download-mom'); + Route::get('/client-calls/notes/{id}/download-todos', [ClientCallController::class, 'downloadTodos'])->name('client-calls.download-todos'); + Route::get('/client-calls/notes/{id}/emotion-details', [ClientCallController::class, 'getEmotionDetails'])->name('client-calls.emotion-details'); + Route::post('/client-calls/notes/{id}/toggle-todo/{todoId}', [ClientCallController::class, 'toggleTodo'])->name('client-calls.toggle-todo'); + Route::get('/client-calls/notes/{id}/angry-analysis', [ClientCallController::class, 'getAngryAnalysis'])->name('client-calls.angry-analysis'); + Route::post('/client-calls/notes/{id}/resend-email', [ClientCallController::class, 'resendMoMEmail'])->name('client-calls.resend-email'); }); +// Client Video Call Room (Accessible to authenticated internal members & external guest clients via link) +Route::get('/client-calls/room/{code}', [ClientCallController::class, 'showRoom'])->name('client-calls.room'); +Route::post('/client-calls/room/{code}/join-guest', [ClientCallController::class, 'joinGuest'])->name('client-calls.join-guest'); +Route::post('/client-calls/room/{code}/detect-emotion', [ClientCallController::class, 'detectEmotion'])->name('client-calls.detect-emotion'); +Route::post('/client-calls/room/{code}/end-call', [ClientCallController::class, 'endCallAndGenerateMoM'])->name('client-calls.end-call'); +Route::post('/client-calls/room/{code}/heartbeat', [ClientCallController::class, 'signalingHeartbeat'])->name('client-calls.heartbeat'); + // Admin Control Panel (requires admin role) Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () { diff --git a/tests/Feature/ClientCallFeatureTest.php b/tests/Feature/ClientCallFeatureTest.php new file mode 100644 index 0000000..e19be4c --- /dev/null +++ b/tests/Feature/ClientCallFeatureTest.php @@ -0,0 +1,461 @@ +pmRole = Role::create(['name' => 'Project Manager', 'description' => 'PM']); + $this->devRole = Role::create(['name' => 'Developer', 'description' => 'Dev']); + + // Create users + $this->admin = User::create([ + 'name' => 'System Admin', + 'email' => 'admin@company.com', + 'role' => 'admin', + ]); + + $this->pm = User::create([ + 'name' => 'John PM', + 'email' => 'john.pm@company.com', + 'role' => 'user', + ]); + $this->pm->roles()->attach($this->pmRole->id); + + $this->developer = User::create([ + 'name' => 'Alex Dev', + 'email' => 'alex.dev@company.com', + 'role' => 'user', + ]); + $this->developer->roles()->attach($this->devRole->id); + } + + public function test_pm_and_admin_can_access_client_calls_and_create_client(): void + { + $this->actingAs($this->pm) + ->get(route('client-calls.index')) + ->assertStatus(200) + ->assertSee('Client Projects', false); + + $response = $this->actingAs($this->pm) + ->post(route('client-calls.store'), [ + 'client_name' => 'Alice Walker', + 'client_email' => 'alice@innovate.io', + 'client_phone' => '+1 555 456 7890', + 'company_name' => 'Innovate Tech', + 'project_name' => 'AI Analytics Dashboard', + 'project_details' => 'Full stack dashboard with live WebRTC meeting rooms and AI transcription.', + 'responsible_user_id' => $this->pm->id, + 'assigned_team_members' => [$this->pm->id, $this->developer->id], + ]); + + $response->assertRedirect(route('client-calls.index')); + + $this->assertDatabaseHas('clients', [ + 'client_name' => 'Alice Walker', + 'client_email' => 'alice@innovate.io', + 'project_name' => 'AI Analytics Dashboard', + 'responsible_user_id' => $this->pm->id, + ]); + } + + public function test_client_hub_page_shows_details_and_opens(): void + { + $client = Client::create([ + 'client_name' => 'Bob Smith', + 'client_email' => 'bob@smithco.com', + 'project_name' => 'Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + 'latest_sentiment' => 'happy', + ]); + + $this->actingAs($this->pm) + ->get(route('client-calls.show', $client->id)) + ->assertStatus(200) + ->assertSee('Cloud Migration') + ->assertSee('Bob Smith') + ->assertSee('Start Instant Meeting') + ->assertSee('Schedule Meeting'); + } + + public function test_scheduling_future_meeting_sends_email_invites(): void + { + Mail::fake(); + + $client = Client::create([ + 'client_name' => 'Bob Smith', + 'client_email' => 'bob@smithco.com', + 'project_name' => 'Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $response = $this->actingAs($this->pm) + ->post(route('client-calls.schedule-meeting', $client->id), [ + 'title' => 'Sprint 1 Architecture Review', + 'agenda' => 'Review AWS diagrams and API contracts.', + 'scheduled_at' => now()->addDays(2)->format('Y-m-d H:i:s'), + 'duration_minutes' => 45, + 'client_emails' => 'bob@smithco.com, ctobob@smithco.com', + 'team_attendees' => [$this->pm->id, $this->developer->id], + ]); + + $response->assertRedirect(route('client-calls.show', $client->id)); + + $this->assertDatabaseHas('client_meetings', [ + 'client_id' => $client->id, + 'title' => 'Sprint 1 Architecture Review', + 'meeting_type' => 'scheduled', + ]); + + Mail::assertSent(ClientMeetingInviteMail::class); + } + + public function test_starting_instant_meeting_generates_shareable_link(): void + { + $client = Client::create([ + 'client_name' => 'Bob Smith', + 'client_email' => 'bob@smithco.com', + 'project_name' => 'Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $response = $this->actingAs($this->pm) + ->postJson(route('client-calls.instant-meeting', $client->id), [ + 'title' => 'Quick Alignment Sync', + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['success', 'meeting_id', 'meeting_code', 'join_url']); + + $this->assertDatabaseHas('client_meetings', [ + 'client_id' => $client->id, + 'meeting_type' => 'instant', + 'status' => 'in_progress', + ]); + } + + public function test_external_guest_can_join_via_meeting_link(): void + { + $client = Client::create([ + 'client_name' => 'Bob Smith', + 'client_email' => 'bob@smithco.com', + 'project_name' => 'Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Instant Sync', + 'meeting_code' => 'meet-test-guest-123', + 'meeting_type' => 'instant', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + // Unauthenticated guest arrives at room -> gets guest entry screen + $this->get(route('client-calls.room', $meeting->meeting_code)) + ->assertStatus(200) + ->assertSee("What's your name?", false); + + // Guest submits name + $this->post(route('client-calls.join-guest', $meeting->meeting_code), [ + 'guest_name' => 'Bob Smith (Client)', + 'guest_email' => 'bob@smithco.com', + ])->assertRedirect(route('client-calls.room', $meeting->meeting_code)); + + // Now has session and enters room + $this->withSession(["guest_name_{$meeting->meeting_code}" => 'Bob Smith (Client)']) + ->get(route('client-calls.room', $meeting->meeting_code)) + ->assertStatus(200) + ->assertSee('Bob Smith (Client)'); + } + + public function test_ai_emotion_detection_endpoint(): void + { + $client = Client::create([ + 'client_name' => 'Bob Smith', + 'client_email' => 'bob@smithco.com', + 'project_name' => 'Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Sync Call', + 'meeting_code' => 'meet-emotion-test', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + // Test angry sentiment detection on frustrated speech text + $response = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'This 2 week delay is totally unacceptable and broken webhooks are terrible.', + ]); + + $response->assertStatus(200) + ->assertJson([ + 'emotion' => 'angry', + ]); + + // Test happy sentiment detection + $happyResponse = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'The demo looks fantastic, awesome work on the features!', + ]); + + $happyResponse->assertStatus(200) + ->assertJson([ + 'emotion' => 'happy', + ]); + } + + public function test_ending_call_generates_mom_and_emails_stakeholders(): void + { + Mail::fake(); + + $client = Client::create([ + 'client_name' => 'Bob Smith', + 'client_email' => 'bob@smithco.com', + 'project_name' => 'Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Sprint Wrap-up Call', + 'meeting_code' => 'meet-wrapup-456', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + $response = $this->actingAs($this->pm) + ->postJson(route('client-calls.end-call', $meeting->meeting_code), [ + 'transcript' => "[10:00 AM] PM: Discussing migration progress.\n[10:10 AM] Client: Progress is good.", + 'live_notes' => 'Agreed on migration date.', + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertDatabaseHas('client_call_notes', [ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + ]); + + $this->assertEquals('completed', $meeting->fresh()->status); + Mail::assertSent(ClientCallMomMail::class); + } + + public function test_angry_analysis_is_strictly_restricted_to_admin_and_responsible_lead(): void + { + $client = Client::create([ + 'client_name' => 'Sarah Connor', + 'client_email' => 'sarah@cyber.io', + 'project_name' => 'Security Portal', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + 'latest_sentiment' => 'angry', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Escalation Review', + 'meeting_code' => 'meet-angry-check', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'raw_transcript' => 'Client is angry about milestone delays.', + 'mom_content' => 'MoM Content', + 'summary' => 'Urgent review', + 'detected_sentiment' => 'angry', + 'angry_analysis' => [ + 'is_angry' => true, + 'trigger_words' => ['unacceptable', 'delay'], + 'summary_why_angry' => 'Client is angry due to milestone delays.', + 'grievances' => ['Delays', 'Lack of updates'], + 'suggested_remedy' => 'Send remedial roadmap.', + ], + ]); + + // 1. Admin CAN view angry analysis + $this->actingAs($this->admin) + ->getJson(route('client-calls.angry-analysis', $note->id)) + ->assertStatus(200) + ->assertJson([ + 'success' => true, + 'detected_sentiment' => 'angry', + ]); + + // 2. Responsible PM CAN view angry analysis + $this->actingAs($this->pm) + ->getJson(route('client-calls.angry-analysis', $note->id)) + ->assertStatus(200) + ->assertJson([ + 'success' => true, + 'detected_sentiment' => 'angry', + ]); + + // 3. Unrelated Developer CANNOT view angry analysis (403 Forbidden) + $this->actingAs($this->developer) + ->getJson(route('client-calls.angry-analysis', $note->id)) + ->assertStatus(403) + ->assertJsonStructure(['error']); + } + + public function test_admin_dashboard_displays_client_projects_and_alerts(): void + { + Client::create([ + 'client_name' => 'Sarah Connor', + 'client_email' => 'sarah@cyber.io', + 'project_name' => 'Security Portal', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + 'latest_sentiment' => 'angry', + ]); + + $this->actingAs($this->admin) + ->get(route('admin.dashboard')) + ->assertStatus(200) + ->assertSee('Client Calls Hub') + ->assertSee('Security Portal') + ->assertSee('Sarah Connor'); + } + + public function test_can_view_call_logs_and_download_mom_and_todos(): void + { + $client = Client::create([ + 'client_name' => 'Robert Miller', + 'client_email' => 'robert@acmecorp.com', + 'project_name' => 'Enterprise Cloud Migration', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + 'latest_sentiment' => 'happy', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Sprint 2 Milestone Demo', + 'meeting_code' => 'meet-logs-test-1', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'raw_transcript' => 'Client is very happy with cloud deployment.', + 'mom_content' => "### Minutes of Meeting\nObjectives: Deploy cloud infrastructure.\nNext steps: Testing.", + 'summary' => 'Client praised the architecture blueprints.', + 'detected_sentiment' => 'happy', + 'todo_items' => [ + ['id' => 1, 'task' => 'Deploy AWS staging templates', 'owner' => 'Engineering Team', 'priority' => 'High', 'deadline' => 'Aug 22, 2026', 'completed' => false], + ['id' => 2, 'task' => 'Provide SSO test credentials', 'owner' => 'Tech Lead', 'priority' => 'Medium', 'deadline' => 'Aug 23, 2026', 'completed' => false], + ], + 'emotion_details' => [ + 'emotion' => 'happy', + 'headline' => 'Client Praised Sprint Progress', + 'bullet_points' => [ + 'Pleased with clear architecture diagram.', + 'Praised team responsiveness.' + ], + 'trigger_quotes' => ['looks great', 'very pleased'], + 'suggested_action' => 'Maintain current sprint momentum.' + ], + ]); + + // 1. Check Date-wise Call Logs view + $this->actingAs($this->pm) + ->get(route('client-calls.logs')) + ->assertStatus(200) + ->assertSee('Date-wise Client Call Logs') + ->assertSee('Enterprise Cloud Migration') + ->assertSee('Sprint 2 Milestone Demo') + ->assertSee('Download MoM (.txt)'); + + // 2. Test Download MoM as .txt + $responseMom = $this->actingAs($this->pm) + ->get(route('client-calls.download-mom', $note->id)); + + $responseMom->assertStatus(200); + $this->assertStringContainsString('attachment;', $responseMom->headers->get('content-disposition')); + $this->assertStringContainsString('MINUTES OF MEETING (MoM)', $responseMom->getContent()); + $this->assertStringContainsString('Enterprise Cloud Migration', $responseMom->getContent()); + + // 3. Test Download To-Dos as .txt + $responseTodos = $this->actingAs($this->pm) + ->get(route('client-calls.download-todos', $note->id)); + + $responseTodos->assertStatus(200); + $this->assertStringContainsString('attachment;', $responseTodos->headers->get('content-disposition')); + $this->assertStringContainsString('ACTIONABLE TO-DO LIST', $responseTodos->getContent()); + $this->assertStringContainsString('Deploy AWS staging templates', $responseTodos->getContent()); + + // 4. Test Fetch Emotion Details (Why happy) + $this->actingAs($this->pm) + ->getJson(route('client-calls.emotion-details', $note->id)) + ->assertStatus(200) + ->assertJson([ + 'success' => true, + 'detected_sentiment' => 'happy', + ]) + ->assertJsonStructure([ + 'emotion_details' => [ + 'emotion', + 'headline', + 'bullet_points', + 'suggested_action', + ] + ]); + + // 5. Test Toggle To-Do status + $this->actingAs($this->pm) + ->postJson(route('client-calls.toggle-todo', ['id' => $note->id, 'todoId' => 1])) + ->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertTrue($note->fresh()->todo_items[0]['completed']); + } +}