964 lines
36 KiB
PHP
964 lines
36 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Mail\ClientMeetingInviteMail;
|
|
use App\Models\Client;
|
|
use App\Models\ClientCallNote;
|
|
use App\Models\ClientMeeting;
|
|
use App\Models\User;
|
|
use App\Services\AiClientCallService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ClientCallController extends Controller
|
|
{
|
|
protected AiClientCallService $aiService;
|
|
|
|
public function __construct(AiClientCallService $aiService)
|
|
{
|
|
$this->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());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|