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(); $activeProjectsCount = $clients->where('status', 'active')->count(); return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'activeProjectsCount')); } /** * 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', 'callNotes.meeting' ])->findOrFail($id); $allUsers = User::orderBy('name', 'asc')->get(); // Group call notes date-wise (Y-m-d) $groupedCallNotes = $client->callNotes->groupBy(function ($note) { return $note->created_at ? $note->created_at->format('Y-m-d') : now()->format('Y-m-d'); }); $canViewAngry = $client->canViewAngryAnalysis($user); return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry', 'groupedCallNotes')); } /** * 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()); $client = $meeting->client; return view('client_calls.room', compact('meeting', 'client', '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 = (string)($request->input('speech_text') ?? ''); $imageFrame = $request->input('image_frame'); $result = $this->aiService->detectEmotion($speechText, $imageFrame); return response()->json($result); } /** * Upload & Save Client Call Video Recording. */ public function uploadRecording(Request $request, $code) { $meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail(); if ($request->hasFile('video') || $request->hasFile('recording')) { $file = $request->file('video') ?? $request->file('recording'); $ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov', 'ogg'])) { $ext = 'webm'; } $subDir = 'uploads/client_recordings/' . $meeting->meeting_code; $destinationPath = public_path($subDir); if (!file_exists($destinationPath)) { mkdir($destinationPath, 0777, true); } $filename = 'call_recording_' . time() . '.' . $ext; $file->move($destinationPath, $filename); $url = asset($subDir . '/' . $filename); $relativePath = '/' . $subDir . '/' . $filename; $recordings = $meeting->recordings ?? []; if (!is_array($recordings)) { $recordings = $meeting->recording_path ? [['url' => $meeting->recording_path, 'full_url' => asset(ltrim($meeting->recording_path, '/')), 'filename' => basename($meeting->recording_path), 'created_at' => now()->toIso8601String()]] : []; } $recordings[] = [ 'url' => $relativePath, 'full_url' => $url, 'filename' => $filename, 'created_at' => now()->toIso8601String(), ]; $meeting->update([ 'recording_path' => $relativePath, 'recordings' => $recordings, ]); // Sync to existing latest call note if present $latestNote = $meeting->callNotes()->first(); if ($latestNote) { $latestNote->update([ 'recording_path' => $relativePath, 'recordings' => $recordings, ]); } return response()->json([ 'success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings, ]); } return response()->json(['success' => false, 'message' => 'No video recording payload received'], 400); } /** * Download Video Recording for a Meeting. */ public function downloadMeetingRecording(Request $request, $id) { $user = Auth::user(); if (!$user || !$user->canAccessClientCalls()) { abort(403, 'Unauthorized'); } $meeting = ClientMeeting::findOrFail($id); $index = (int) $request->input('index', 0); $recordings = $meeting->recordings ?? []; $targetUrl = null; if (!empty($recordings) && isset($recordings[$index])) { $rec = $recordings[$index]; $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; } if (!$targetUrl) { $targetUrl = $meeting->recording_path; } if (!$targetUrl) { return back()->with('error', 'No video recording found for this meeting.'); } return $this->serveRecordingDownload($targetUrl, $meeting->title ?? 'Meeting'); } /** * Download Video Recording for a Call Note. */ public function downloadNoteRecording(Request $request, $id) { $user = Auth::user(); if (!$user || !$user->canAccessClientCalls()) { abort(403, 'Unauthorized'); } $callNote = ClientCallNote::with(['client', 'meeting'])->findOrFail($id); $index = (int) $request->input('index', 0); $recordings = $callNote->recordings ?? ($callNote->meeting->recordings ?? []); $targetUrl = null; if (!empty($recordings) && isset($recordings[$index])) { $rec = $recordings[$index]; $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; } if (!$targetUrl) { $targetUrl = $callNote->recording_path ?? ($callNote->meeting->recording_path ?? null); } if (!$targetUrl) { return back()->with('error', 'No video recording found for this call note.'); } $projectName = $callNote->client->project_name ?? 'ClientCall'; return $this->serveRecordingDownload($targetUrl, $projectName); } /** * Helper to download recording file. */ protected function serveRecordingDownload(string $targetUrl, string $prefixName) { $urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl; $sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $candidatePaths = [ public_path($sanitizedPath), public_path('storage/' . $sanitizedPath), storage_path('app/public/' . $sanitizedPath), storage_path('app/' . $sanitizedPath), ]; $filePath = null; foreach ($candidatePaths as $cp) { if (file_exists($cp) && is_file($cp)) { $filePath = $cp; break; } } if ($filePath && file_exists($filePath)) { $ext = pathinfo($filePath, PATHINFO_EXTENSION) ?: 'webm'; $slug = Str::slug($prefixName); $downloadFilename = "Recording-{$slug}-" . date('Y-m-d') . ".{$ext}"; return response()->download($filePath, $downloadFilename, [ 'Content-Type' => ($ext === 'webm' ? 'video/webm' : 'video/mp4'), ]); } return back()->with('error', 'Recording file not found on server storage.'); } /** * 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', []); $recordingPath = $request->input('recording_path', $meeting->recording_path); $recordings = $request->input('recordings', $meeting->recordings ?? []); if ($recordingPath && empty($recordings)) { $recordings = [ [ 'url' => $recordingPath, 'full_url' => asset(ltrim($recordingPath, '/')), 'filename' => basename($recordingPath), 'created_at' => now()->toIso8601String(), ] ]; } // Mark meeting as completed $meeting->update([ 'status' => 'completed', 'ended_at' => now(), 'recording_path' => $recordingPath, 'recordings' => $recordings, ]); // 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'] ?? [], 'recording_path' => $recordingPath, 'recordings' => $recordings, ]); // Automatically send email with MoM and notes to TL, Director, PM, and Admin $emailStatus = $this->aiService->sendMoMEmails($callNote); $redirectUrl = Auth::check() ? route('client-calls.show', $client->id) : url('/'); 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, 'recording_path' => $callNote->recording_path, 'redirect_url' => $redirectUrl, ]); } if (!Auth::check()) { return redirect('/')->with('success', 'Meeting ended successfully.'); } 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, 'todo_items' => $callNote->todo_items ?? $callNote->action_items ?? [], 'summary' => $callNote->summary, 'mom_content' => $callNote->mom_content, '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, ]); } /** * Add a new To-Do item to the call note. */ public function addTodo(Request $request, $noteId) { $user = Auth::user(); if (!$user || !$user->canAccessClientCalls()) { return response()->json(['error' => 'Unauthorized'], 403); } $request->validate([ 'task' => 'required|string|max:500', 'owner' => 'nullable|string|max:255', 'priority' => 'nullable|in:High,Medium,Low', 'deadline' => 'nullable|string|max:100', ]); $callNote = ClientCallNote::findOrFail($noteId); $todos = $callNote->todo_items ?? []; $maxId = 0; foreach ($todos as $t) { if (isset($t['id']) && $t['id'] > $maxId) { $maxId = $t['id']; } } $newTodo = [ 'id' => $maxId + 1, 'task' => trim($request->task), 'owner' => $request->owner ?: ($user->name ?? 'Assigned Lead'), 'priority' => $request->priority ?: 'Medium', 'deadline' => $request->deadline ?: now()->addDays(3)->format('M d, Y'), 'completed' => false, ]; $todos[] = $newTodo; $callNote->update(['todo_items' => $todos]); return response()->json([ 'success' => true, 'todo' => $newTodo, 'todo_items' => $todos, ]); } /** * Delete a To-Do item from the call note. */ public function deleteTodo(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 ?? []; $filteredTodos = array_values(array_filter($todos, function ($item) use ($todoId) { return !isset($item['id']) || (int)$item['id'] !== (int)$todoId; })); $callNote->update(['todo_items' => $filteredTodos]); return response()->json([ 'success' => true, 'todo_items' => $filteredTodos, ]); } /** * 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()); } } } } }