'calllogs.browse', 'create' => 'calllogs.create', 'update' => 'calllogs.update', 'destroy' => 'calllogs.destroy', ]; protected $showRoute = 'calllogs.calllogs.show'; protected $storeRequest = CallLogApiRequest::class; protected $updateRequest = UpdateCallLogApiRequest::class; // ── List ────────────────────────────────────────────────────────────────── /** * List call logs with optional filtering, sorting, and pagination. * * Supported query parameters: * - search : searches phone_number, subject, notes, linkedid * - direction : 'inbound' | 'outbound' * - status : partial match on status * - source : partial match on source * - outcome : partial match on outcome * - user_id : filter by agent user ID * - lead_id : filter by linked lead * - contact_id : filter by linked contact * - call_date_from : call date on/after (Y-m-d) * - call_date_to : call date on/before (Y-m-d) * - follow_up_from : follow-up date on/after (Y-m-d) * - follow_up_to : follow-up date on/before (Y-m-d) * - created_from : created at on/after (Y-m-d) * - created_to : created at on/before (Y-m-d) * - sort_by : phone_number|call_date|status|source|queue|outcome|created_at * - sort_dir : asc|desc (default desc) * - per_page : integer (default 15) */ public function index(Request $request) { $this->checkCompanyContext(); if ($this->permissions['browse'] && !\Auth::user()->hasPermissionTo($this->permissions['browse'])) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $query = CallLog::with($this->with); if ($this->scopedAccess()) { $scope = new OwnableEntityScope(\Auth::user(), $this->moduleName, $this->entityClass); $query = $scope->apply($query); } $this->applyRoleBasedFiltering($query); // Full-text search if ($search = $request->get('search')) { $term = '%' . $search . '%'; $query->where(function ($q) use ($term) { $q->where('phone_number', 'like', $term) ->orWhere('subject', 'like', $term) ->orWhere('notes', 'like', $term) ->orWhere('linkedid', 'like', $term); }); } // Exact / partial filters foreach (['direction', 'status', 'source', 'queue', 'outcome'] as $col) { if ($request->filled($col)) { $query->where($col, 'like', '%' . $request->get($col) . '%'); } } foreach (['user_id', 'lead_id', 'contact_id'] as $col) { if ($request->filled($col)) { $ids = array_filter(explode(',', (string) $request->get($col))); if (!empty($ids)) { $query->whereIn($col, $ids); } } } // Date-range filters $dateRanges = [ 'call_date' => ['call_date_from', 'call_date_to'], 'follow_up_date' => ['follow_up_from', 'follow_up_to'], 'created_at' => ['created_from', 'created_to'], ]; foreach ($dateRanges as $col => [$fromKey, $toKey]) { if ($request->filled($fromKey)) { $query->whereDate($col, '>=', $request->get($fromKey)); } if ($request->filled($toKey)) { $query->whereDate($col, '<=', $request->get($toKey)); } } // Sorting $sortable = ['phone_number', 'call_date', 'status', 'source', 'queue', 'outcome', 'created_at', 'updated_at']; $sortBy = $request->filled('sort_by') && in_array($request->get('sort_by'), $sortable, true) ? $request->get('sort_by') : 'created_at'; $sortDir = strtolower((string) $request->get('sort_dir', 'desc')) === 'asc' ? 'asc' : 'desc'; $query->orderBy($sortBy, $sortDir); $perPage = max(1, (int) $request->get('per_page', self::DEFAULT_PER_PAGE)); $paginated = $query->paginate($perPage); // Append API index fields from CRM settings $user = \Auth::user(); $service = app(CallLogFieldSettingsService::class); $apiIndexFields = $service->getApiIndexFields($user); $collection = $paginated->getCollection(); $paginatedArray = $paginated->toArray(); $paginatedArray['data'] = array_map( function ($item) use ($apiIndexFields, $collection) { $entity = $collection->firstWhere('id', $item['id']); $indexFields = []; if ($entity) { foreach ($apiIndexFields as $fieldDef) { $indexFields[] = [ 'key' => $fieldDef['key'], 'label' => $fieldDef['label'], 'value' => $this->resolveApiFieldValue($entity, $fieldDef), ]; } } $record = $entity ? $this->prepareCallLogForResponse($entity) : ['id' => $item['id']]; $record['index_fields'] = $indexFields; return $record; }, $paginatedArray['data'] ); return $this->respond(true, [$paginatedArray], [], ['message' => trans('core::core.entity.records_found')]); } // ── Single record ───────────────────────────────────────────────────────── public function get($identifier) { $this->checkCompanyContext(); if ($this->permissions['browse'] && !\Auth::user()->hasPermissionTo($this->permissions['browse'])) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $entity = CallLog::with($this->with)->find($identifier); if (!$entity) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } if (!$this->userCanView($entity)) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $user = \Auth::user(); $service = app(CallLogFieldSettingsService::class); $fields = $service->getVisibleFieldsForApi($user); $validationRules = $service->getValidationRules($user); $optionsMap = $this->buildCallLogOptionsMap(); $rules = $this->buildApiDependencyRules($fields, $optionsMap); $fieldEntries = []; $fieldMap = []; foreach ($fields as $field) { $meta = $this->buildFieldEntry($entity, $field, $validationRules, $optionsMap); $fieldEntries[] = $meta; $fieldMap[$meta['key']] = $meta; } return $this->respond(true, [ 'call_log' => $this->prepareCallLogForResponse($entity), 'fields' => $fieldEntries, 'field_map' => $fieldMap, 'rules' => $rules, ], [], ['message' => trans('core::core.entity.entity_found')]); } // ── Create ──────────────────────────────────────────────────────────────── public function store() { $this->checkCompanyContext(); $request = \App::make($this->storeRequest); if ($this->permissions['create'] && !\Auth::user()->hasPermissionTo($this->permissions['create'])) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $svc = app(CallLogFieldSettingsService::class); $customKeys = array_column( $svc->getVisibleCustomFieldsForDatatable(\Auth::user()), 'key' ); // Built-in fields (exclude the custom_data slot and custom field keys) $builtInFillable = array_diff((new CallLog())->getFillable(), array_merge(['custom_data'], $customKeys, ['call_date', 'phone_number', 'direction'])); $data = $request->only($builtInFillable); // Map any flat custom field values into the custom_data JSON column $customData = array_filter($request->only($customKeys), fn($v) => $v !== null); if (!empty($customData)) { $data['custom_data'] = $customData; } $entity = CallLog::create($data); // Auto-stamp the authenticated agent if (!$entity->user_id) { $entity->user_id = \Auth::id(); $entity->save(); } $entity->load($this->with); return $this->respond(true, $this->prepareCallLogForResponse($entity), [], ['message' => trans('core::core.entity.created')]); } // ── Update ──────────────────────────────────────────────────────────────── public function update($identifier) { $this->checkCompanyContext(); $request = \App::make($this->updateRequest); if ($this->permissions['update'] && !\Auth::user()->hasPermissionTo($this->permissions['update'])) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $entity = CallLog::find($identifier); if (!$entity) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } $svc = app(CallLogFieldSettingsService::class); $customKeys = array_column( $svc->getVisibleCustomFieldsForDatatable(\Auth::user()), 'key' ); $builtInFillable = array_diff((new CallLog())->getFillable(), array_merge(['custom_data'], $customKeys, ['call_date', 'phone_number', 'direction'])); $data = $request->only($builtInFillable); // Merge flat custom fields into custom_data, preserving any existing values $incoming = array_filter($request->only($customKeys), fn($v) => $v !== null); if (!empty($incoming)) { $existing = is_array($entity->custom_data) ? $entity->custom_data : []; $data['custom_data'] = array_merge($existing, $incoming); } $entity->update($data); $entity->load($this->with); return $this->respond(true, $this->prepareCallLogForResponse($entity), [], ['message' => trans('core::core.entity.updated')]); } // ── Delete ──────────────────────────────────────────────────────────────── public function destroy($identifier) { $this->checkCompanyContext(); if ($this->permissions['destroy'] && !\Auth::user()->hasPermissionTo($this->permissions['destroy'])) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $entity = CallLog::find($identifier); if (!$entity) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } $entity->delete(); return $this->respond(true, [], [], ['message' => trans('core::core.entity.deleted')]); } // ── Role-based access ────────────────────────────────────────────────────── private function applyRoleBasedFiltering($query): void { $user = \Auth::user(); if ($user->hasRole('admin')) { return; } if ($user->hasRole('user')) { $query->where(function ($q) use ($user) { $q->where('user_id', $user->id) ->orWhereNull('user_id'); }); $query->whereIn('direction', ['inbound', 'outbound']); return; } if ($user->hasRole('company_manager') || $user->access_to_all_entity) { $query->where(function ($q) use ($user) { $q->whereHas('user', function ($sub) use ($user) { $sub->where('company_id', $user->company_id); })->orWhereNull('user_id'); }); return; } $query->where(function ($q) use ($user) { $q->where('user_id', $user->id) ->orWhereNull('user_id'); }); } private function userCanView(CallLog $callLog): bool { $user = \Auth::user(); if ($user->hasRole('admin')) { return true; } if ($user->hasRole('user')) { return $callLog->user_id === $user->id && in_array($callLog->direction, ['inbound', 'outbound'], true); } if ($user->hasRole('company_manager') || $user->access_to_all_entity) { return true; } return $callLog->user_id === $user->id; } // ── Response preparation ─────────────────────────────────────────────────── /** * Flatten custom_data into top-level keys and strip internal columns. * Consumers receive a uniform object with no distinction between built-in * and custom fields. */ private function prepareCallLogForResponse(CallLog $entity): array { $entity->loadMissing($this->with); $data = $entity->toArray(); // Collect any values already stored in custom_data. $storedCustom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; unset($data['custom_data']); // always removed — internal storage detail // Guarantee every configured custom field appears in the record, // even when a call log was created before the field was added. $customFields = app(CallLogFieldSettingsService::class) ->getVisibleCustomFieldsForDatatable(\Auth::user()); foreach ($customFields as $field) { $data[$field['key']] = $storedCustom[$field['key']] ?? null; } return $data; } // ── Field schema ────────────────────────────────────────────────────────── /** * Return complete field schema driven by CRM field settings. * * Response is identical in structure to GET /api/leads/fields and * GET /api/tickets/fields, ensuring API clients can use a single * data-driven form-building strategy across all CRM modules. */ public function fields() { $this->checkCompanyContext(); $user = \Auth::user(); $service = app(CallLogFieldSettingsService::class); $visibleFields = $service->getVisibleFieldsForApi($user); $validationRules = $service->getValidationRules($user); $optionsMap = $this->buildCallLogOptionsMap(); $rules = $this->buildApiDependencyRules($visibleFields, $optionsMap); $fieldEntries = []; $fieldMap = []; foreach ($visibleFields as $field) { $entry = $this->buildFieldSchema($field, $validationRules, $optionsMap); $fieldEntries[] = $entry; $fieldMap[$entry['key']] = $entry; } return $this->respond(true, [ 'fields' => $fieldEntries, 'field_map' => $fieldMap, 'rules' => $rules, ], [], ['message' => 'fields fetched successfully']); } // ── Value resolution ────────────────────────────────────────────────────── /** * Required by CrmFieldApiTrait::buildFieldEntry(). */ protected function resolveApiFieldValue(object $entity, array $field): mixed { $key = $field['key']; // Custom fields → read from custom_data JSON if ($field['is_custom'] ?? false) { $custom = is_array($entity->custom_data) ? $entity->custom_data : []; return $custom[$key] ?? null; } return $entity->{$key} ?? null; } /** * Required by CrmFieldApiTrait::buildFieldEntry(). */ protected function resolveApiDisplayValue(object $entity, array $field, mixed $value, array $optionsMap): mixed { $key = $field['key']; if ($field['is_custom'] ?? false) { return $value; } // Relation fields — return the related entity's name $relationMap = [ 'lead_id' => ['lead', 'full_name'], 'contact_id' => ['contact', 'full_name'], 'user_id' => ['user', 'name'], ]; if (isset($relationMap[$key])) { [$relation, $column] = $relationMap[$key]; return optional($entity->{$relation})->{$column}; } // Select fields with a static options list — look up the label if (array_key_exists($key, $optionsMap) && $value !== null) { foreach ($optionsMap[$key] as $opt) { if ((string) ($opt['id'] ?? '') === (string) $value) { return $opt['name']; } } } return $value; } // ── Options map ─────────────────────────────────────────────────────────── private function buildCallLogOptionsMap(): array { $companyId = \Auth::user()->company_id; return [ 'direction' => [ ['id' => 'inbound', 'name' => 'Inbound'], ['id' => 'outbound', 'name' => 'Outbound'], ['id' => 'missed', 'name' => 'Missed'], ], 'status' => [ ['id' => 'New', 'name' => 'New'], ['id' => 'In Progress', 'name' => 'In Progress'], ['id' => 'Follow-Up Scheduled', 'name' => 'Follow-Up Scheduled'], ['id' => 'Pending Response', 'name' => 'Pending Response'], ['id' => 'Closed - Won', 'name' => 'Closed - Won'], ['id' => 'Closed - Lost', 'name' => 'Closed - Lost'], ['id' => 'Not Interested', 'name' => 'Not Interested'], ], 'source' => [ ['id' => 'Softphone', 'name' => 'Softphone'], ['id' => 'Campaign', 'name' => 'Campaign'], ['id' => 'Inbound Web', 'name' => 'Inbound Web'], ['id' => 'Referral', 'name' => 'Referral'], ['id' => 'WhatsApp', 'name' => 'WhatsApp'], ['id' => 'Email', 'name' => 'Email'], ['id' => 'Manual', 'name' => 'Manual'], ['id' => 'Other', 'name' => 'Other'], ], 'outcome' => [ ['id' => 'Answered - Interested', 'name' => 'Answered - Interested'], ['id' => 'Answered - Not Interested', 'name' => 'Answered - Not Interested'], ['id' => 'Left Voicemail', 'name' => 'Left Voicemail'], ['id' => 'No Answer', 'name' => 'No Answer'], ['id' => 'Callback Requested', 'name' => 'Callback Requested'], ['id' => 'Follow-Up Needed', 'name' => 'Follow-Up Needed'], ['id' => 'Sale Closed', 'name' => 'Sale Closed'], ['id' => 'Other', 'name' => 'Other'], ], ]; } // ── Lookup helpers (match Leads API pattern) ────────────────────────────── public function allStatuses() { $svc = app(\Modules\CallLogs\Service\CallLogFieldSettingsService::class); $options = $svc->getEffectiveFields(\Auth::user())['status']['options'] ?? []; return $this->respond(true, ['user' => $options], [], 'data fetched successfully'); } public function allSources() { $svc = app(\Modules\CallLogs\Service\CallLogFieldSettingsService::class); $options = $svc->getEffectiveFields(\Auth::user())['source']['options'] ?? []; return $this->respond(true, ['user' => $options], [], 'data fetched successfully'); } public function allOutcomes() { $svc = app(\Modules\CallLogs\Service\CallLogFieldSettingsService::class); $options = $svc->getEffectiveFields(\Auth::user())['outcome']['options'] ?? []; return $this->respond(true, ['user' => $options], [], 'data fetched successfully'); } // ── Active call ─────────────────────────────────────────────────────────── /** * GET /api/calllogs/active-linkedid * * Returns the Asterisk LinkedID of the authenticated agent's current active * call by querying CoreShowChannels over AMI. * * Response: { "linkedid": "" } or { "linkedid": null } when idle. */ public function activeLinkedid() { $user = \Auth::user(); $extension = (string) $user->id; try { $astman = new \App\Http\Controllers\AstmanController(); $response = $astman->doRequest('CoreShowChannels'); if (empty($response)) { return $this->respond(true, ['linkedid' => null], [], 'No active call'); } $blocks = explode('Event: CoreShowChannel', $response); array_shift($blocks); foreach ($blocks as $block) { if (!preg_match('/^\s*Channel:\s*PJSIP\/' . preg_quote($extension, '/') . '-/im', $block)) { continue; } if (preg_match('/^\s*Linkedid:\s*([^\r\n]+)/im', $block, $m)) { return $this->respond(true, ['linkedid' => trim($m[1])], [], 'Active call found'); } } return $this->respond(true, ['linkedid' => null], [], 'No active call'); } catch (\Throwable $th) { Log::warning("activeLinkedid API failed for user {$user->id}: " . $th->getMessage()); return $this->respond(true, ['linkedid' => null], [], 'No active call'); } } }