'contacts.browse', 'create' => 'contacts.create', 'update' => 'contacts.update', 'destroy' => 'contacts.destroy' ]; private $coreShowChannelsCache = null; protected $userImportService; public function __construct(UserImportService $userImportService) { $this->middleware('jwt.auth')->except('login'); $this->userImportService = $userImportService; } /** * Resolve a user the actor is trying to manage. * * Super admins (settings.access) can resolve a user of any company — the * manageable check is decided later by User::canManageUser(), exactly like * UserController::destroy() uses findWithoutFail() without a company scope. * Every other user is resolved within their own company only. * * @param int|string $id * @param User $actor * @return User|null */ protected function findUserForActor($id, User $actor) { $query = User::where('id', $id)->whereNull('deleted_at'); if (!$actor->isAdmin()) { $query->where('company_id', $actor->company_id); } return $query->first(); } /** * Get all users (paginated). * * Follows the exact user visibility rules of the Web User Management * (UsersDatatable): * - Super admins (settings.access + Admin role): every user, every company. * - Everyone else: users of the current company. * - Scoped user.settings users (not company admins): users holding a * protected Admin / Company Admin role are hidden. */ public function index(Request $request) { try { $perPage = $request->get('per_page', 10); $user = auth()->user(); // Same current-company resolution as the Web (CurrentCompanyMiddleware). $this->checkCompanyContext(); $query = User::whereNull('deleted_at'); if ($user->isAdmin() && $user->hasRole('admin')) { // Super admin: no company restriction (see UsersDatatable::query). } else { $query->where('company_id', Landlord::getTenantId('company_id')); } // Only super admins and company admins may see users holding a // protected role (Admin / Company Admin). if (!$user->canManageCompanyAccess()) { $query->excludingProtectedRoles(); } $users = $query->paginate($perPage); // Transform user data to match $responseData structure $transformed = $users->getCollection()->map(function ($entity) { return [ 'extension' => $entity->id ?? null, 'first_name' => $entity->first_name ?? null, 'last_name' => $entity->last_name ?? null, 'email' => $entity->email ?? null, 'server' => config('app.url'), 'created_at' => optional($entity->created_at)->format('Y-m-d H:i:s'), ]; }); // Replace collection with transformed data $users->setCollection($transformed); return response()->json([ 'success' => true, 'data' => $users ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage() ], 500); } } /** * Create a single user (one user per request). * * Uses the exact same logic, conditions, validation and creation flow as * the CSV User Import (UserImportService). When `extension` is not * provided it is auto-generated the same way a blank CSV Extension cell * is — the id is left out and assigned by MySQL auto-increment. */ public function store(Request $request) { if (auth()->check()) { Landlord::addTenant('company_id', auth()->user()->company_id); } try { $entity = $this->userImportService->create([ 'extension' => $request->input('extension'), 'name' => $request->input('name'), 'email' => $request->input('email'), 'password' => $request->input('password'), ]); $responseData = [ 'extension' => $entity->id ?? null, 'name' => $entity->name ?? null, 'first_name' => $entity->first_name ?? null, 'last_name' => $entity->last_name ?? null, 'email' => $entity->email ?? null, 'server' => config('app.url'), ]; return response()->json([ 'success' => true, 'message' => 'User created successfully.', 'data' => $responseData, ], 201); } catch (UserImportValidationException $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage(), 'errors' => $e->errors(), ], 422); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage(), ], 500); } } /** * Delete a user by ID. * * Same access rule as the Web UserController::destroy(): full * settings-access admins may delete any user, otherwise the target must be * manageable by the actor (own company, and never a protected Admin / * Company Admin user for scoped user.settings users). */ public function destroy($id) { try { $actor = auth()->user(); // Same current-company resolution as the Web (CurrentCompanyMiddleware). $this->checkCompanyContext(); $user = $this->findUserForActor($id, $actor); if (!$user) { return response()->json([ 'success' => false, 'message' => 'User not found.' ], 404); } // Same rule as UserController::destroy(): full settings-access admins // may delete anyone, otherwise the user must be manageable by the // actor (own company, and never a protected Admin / Company Admin // user for scoped user.settings users). if (!$actor->isAdmin() && !$actor->canManageUser($user)) { return response()->json([ 'success' => false, 'message' => 'You do not have permission to manage this user.' ], 403); } $user->delete(); return response()->json([ 'success' => true, 'message' => 'User deleted successfully.' ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage() ], 500); } } /** * Get the logged-in user's current status. * * Same logic as the Dashboard Users section: live Asterisk data (PJSIP * contacts, channels, queue membership) gathered with the same traits used * by the reporting dashboard. Only the status string is returned. */ public function status() { try { /** @var User $user */ $user = auth()->user(); $extension = (string) $user->id; $allowed = [$extension, '99' . $extension]; $contacts = $this->PJSIPShowContacts2($allowed); $channels = $this->CoreShowChannels($allowed); $channelList = is_array($channels) ? ($channels['channels'] ?? []) : []; $queueData = $this->getOfferedCompletedMissedCalls($allowed, [], []); $agents = is_array($queueData) ? ($queueData['agents'] ?? []) : []; $status = $this->resolveUserStatus($extension, $contacts, $channelList, $agents); return response()->json([ 'success' => true, 'data' => $status ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage() ], 500); } } /** * Resolve a user's status. * * Mirrors the Dashboard Users section (reporting.js -> populateUsers): * 1. QueueStatus owns the agent's Available / In Call state * ('Avail', 'In Call', 'Out Call', 'Ringing', pause reason, ...). * Queue membership without a PJSIP registration => 'Offline'. * 2. Not a queue member: fall back to the live CoreShowChannels device * channel ('Ringing' / 'In Call' / 'Out Call' / raw state). * 3. Nothing at all => 'Offline'. * * Local/@from-queue queue bridge legs are never used: they survive a * transfer and would otherwise keep an idle agent "In Call" until the call * finally ends. */ private function resolveUserStatus(string $extension, array $contacts, array $channels, array $agents): string { // 1. QueueStatus is the source of truth for the agent's state. $agent = $agents[$extension] ?? null; if (is_array($agent)) { $registered = false; foreach ($contacts as $c) { if ((string) ($c['extension'] ?? '') === $extension) { $registered = true; break; } } // Queue membership but no PJSIP registration — unreachable wins. if (!$registered) { return 'Offline'; } $agentStatus = $agent['status'] ?? []; $agentStatus = is_array($agentStatus) ? $agentStatus : []; if (($agentStatus['status'] ?? '') === 'Paused') { // Same as the dashboard: show the pause reason when set // (e.g. 'Break', 'Task', 'Meeting'), else 'Paused'. $reason = (string) ($agentStatus['reason'] ?? ''); return $reason !== '' ? $reason : 'Paused'; } // The queue is ringing this agent right now: QueueStatus already // marks the member busy while the device is still ringing, so take // "Ringing" from the agent's own device channel - the same source // the Active Calls list uses - instead of the queue state. foreach ($channels as $ch) { if ((string) ($ch['extension'] ?? '') !== $extension) { continue; } $channelState = strtolower((string) ($ch['status'] ?? '')); if (in_array($channelState, ['ringing', 'ring'], true)) { return 'Ringing'; } break; // same as reporting.js: only the first channel wins } if (($agentStatus['status'] ?? '') !== '') { return $agentStatus['status']; } } // 2. Not a queue member (or no queue state): fall back to the agent's // live device channel so non-queue users still show their call state. foreach ($channels as $ch) { if ((string) ($ch['extension'] ?? '') === $extension) { $state = strtolower((string) ($ch['status'] ?? '')); if (in_array($state, ['ringing', 'ring'], true)) { return 'Ringing'; } if ($state === 'up') { $direction = $ch['direction'] ?? 'outbound'; return $direction === 'inbound' ? 'In Call' : 'Out Call'; } return $ch['status'] ?? 'Unknown'; } } return 'Offline'; } }