'leads.browse', 'create' => 'leads.create', 'update' => 'leads.update', 'destroy' => 'leads.destroy' ]; // Needed for notifications protected $showRoute = 'leads.leads.show'; protected $storeRequest = LeadApiRequest::class; protected $updateRequest = UpdateLeadApiRequest::class; public function __construct() { parent::__construct(); } /** * Create a lead. Custom fields registered in CRM settings (provisioned columns) * are included in the create payload even though they are not in $fillable, * by passing them directly to createEntity() via setAttribute(). * * @return \Illuminate\Http\JsonResponse */ public function store() { $this->checkCompanyContext(); $request = \App::make($this->storeRequest ?? Request::class); 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')]); } $repository = $this->getRepository(); $entityClass = \App::make($this->entityClass); // Base fillable fields $fillableData = $request->only($entityClass->getFillable()); // Merge in any provisioned custom-field columns that are not in $fillable $user = \Auth::user(); $fieldService = app(LeadFieldSettingsService::class); $customKeys = $fieldService->getCustomFieldKeys($user); foreach ($customKeys as $key) { if ($request->has($key) && !array_key_exists($key, $fillableData)) { $fillableData[$key] = $request->input($key); } } // Always derive full_name from first_name + last_name. $fillableData['full_name'] = trim( ($fillableData['first_name'] ?? '') . ' ' . ($fillableData['last_name'] ?? '') ); $entity = $repository->createEntity($fillableData, $entityClass); $entity = $this->setupAssignedTo($entity, $request->all(), true); // The created entry is already logged automatically by LogsActivity during // createEntity(). Suppress this bookkeeping save so owner assignment right // after creation does not add a second 'updated' activity entry. $entity->disableLogging(); $entity->save(); $entity->enableLogging(); $this->entity = $repository->with($this->with)->findWithoutFail($entity->id); return $this->respond(true, $this->prepareLeadForResponse($this->entity), [], ['message' => trans('core::core.entity.created')]); } /** * Update a lead. Custom fields registered in CRM settings are stored in their * own DB columns (auto-provisioned by LeadFieldSettingsService). The response * excludes columns for any custom fields that have been deleted from CRM settings. * * @param mixed $identifier * @return \Illuminate\Http\JsonResponse */ public function update($identifier) { $this->checkCompanyContext(); $request = \App::make($this->updateRequest ?? \Illuminate\Http\Request::class); 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')]); } $repository = $this->getRepository(); $entity = $repository->findWithoutFail($identifier); $this->entity = $entity; if (empty($entity)) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } if ($this->blockEntityOwnableAccess()) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $input = $request->all(); // Always derive full_name from first_name + last_name. When the request // omits one of them, fall back to the value already stored on the lead. $firstName = array_key_exists('first_name', $input) ? $input['first_name'] : $entity->first_name; $lastName = array_key_exists('last_name', $input) ? $input['last_name'] : $entity->last_name; $input['full_name'] = trim(($firstName ?? '') . ' ' . ($lastName ?? '')); $entity = $this->setupAssignedTo($entity, $input); $repository = $this->getRepository(); $entity = $repository->updateEntity($input, $entity); $lead = $repository->with($this->with)->findWithoutFail($identifier); $this->entity = $lead; return $this->respond(true, $this->prepareLeadForResponse($lead), [], ['message' => trans('core::core.entity.updated')]); } /** * Prepare a Lead for API response: * 1. Hide provisioned columns removed from CRM settings. * 2. Flatten remaining custom_fields JSON entries into top-level keys. * 3. Add computed owned_by string ("user-{id}" / "group-{id}"). * 4. Strip internal/sensitive columns from the payload. */ private function prepareLeadForResponse(Lead $lead): array { $user = \Auth::user(); $fieldService = app(LeadFieldSettingsService::class); $currentCustomKeys = $fieldService->getCustomFieldKeys($user); $provisionedKeys = $fieldService->getProvisionedColumnKeys($user); $orphanedKeys = array_diff($provisionedKeys, $currentCustomKeys); $data = $lead->toArray(); // Hide columns for custom fields deleted from CRM settings foreach ($orphanedKeys as $key) { unset($data[$key]); } // Flatten remaining custom_fields JSON entries into top-level keys, // then unconditionally remove the raw column from the response. if (is_array($data['custom_fields'] ?? null)) { // First remove keys that now have real provisioned columns foreach ($provisionedKeys as $key) { unset($data['custom_fields'][$key]); } // Promote remaining JSON custom fields to top level foreach ($data['custom_fields'] as $key => $value) { $data[$key] = $value; } } unset($data['custom_fields']); // always removed — internal storage detail // Add a computed owned_by field ("user-{id}" or "group-{id}") if (!empty($lead->owned_by_id) && !empty($lead->owned_by_type)) { $ownerType = class_basename($lead->owned_by_type); $prefix = (strtolower($ownerType) === 'group') ? 'group' : 'user'; $data['owned_by'] = $prefix . '-' . $lead->owned_by_id; if ($lead->relationLoaded('ownedBy') && $lead->ownedBy !== null) { $data['owned_by_name'] = $lead->ownedBy->name ?? null; } } else { $data['owned_by'] = null; $data['owned_by_name'] = null; } // Remove internal / sensitive columns unset($data['owned_by_type'], $data['owned_by_id'], $data['deleted_at']); return $data; } /** * Get lead details with field metadata for data-driven clients. * * @param mixed $identifier * @return \Illuminate\Http\JsonResponse */ 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')]); } $repository = $this->getRepository(); $entity = $repository->with($this->with)->findWithoutFail($identifier); $this->entity = $entity; if (empty($entity)) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } if ($this->blockEntityOwnableAccess()) { return $this->respond(false, [], ['error' => 'user_dont_no_access'], ['message' => trans('core::core.you_dont_have_access')]); } $this->entityIdentifier = $entity->id; $this->entity = $entity; $user = \Auth::user(); $fieldSettingsService = app(LeadFieldSettingsService::class); $fields = $fieldSettingsService->getVisibleFieldsForApi($user); $validationRules = $fieldSettingsService->getValidationRules($user); $optionsMap = $this->getLeadFieldOptionsMap($user->company_id); $rules = $this->buildLeadFieldRules($fields, $optionsMap); $fieldEntries = []; $fieldMap = []; foreach ($fields as $field) { $metadata = $this->buildLeadFieldMetadata($entity, $field, $validationRules, $optionsMap); $fieldEntries[] = $metadata; $fieldMap[$metadata['key']] = $metadata; } return $this->respond(true, [ 'lead' => $this->prepareLeadForResponse($entity), 'fields' => $fieldEntries, 'field_map' => $fieldMap, 'rules' => $rules, ], [], ['message' => trans('core::core.entity.entity_found')]); } /** * List leads with optional filters. * * Supported query parameters: * - search : searches first_name, last_name, full_name, phone, mobile, email * - lead_status_id : filter by status ID (int or comma-separated IDs) * - lead_source_id : filter by source ID * - lead_industry_id: filter by industry/category ID * - lead_rating_id : filter by rating ID * - email : exact/partial email filter * - phone : exact/partial phone filter * - mobile : exact/partial mobile filter * - company_name : partial company name filter * - job_title : partial job title filter * - created_from : leads created on/after this date (Y-m-d) * - created_to : leads created on/before this date (Y-m-d) * - per_page : records per page (default 15) * * @param Request $request * @return \Illuminate\Http\JsonResponse */ public function index(Request $request) { $this->checkCompanyContext(); $query = \App::make($this->entityClass)->with($this->with); if ($this->scopedAccess()) { $scope = new OwnableEntityScope(\Auth::user(), $this->moduleName, $this->entityClass); $query = $scope->apply($query); } // Full-text search across common identifiers if ($search = $request->get('search')) { $term = '%' . $search . '%'; $query->where(function ($q) use ($term) { $q->where('first_name', 'like', $term) ->orWhere('last_name', 'like', $term) ->orWhere('full_name', 'like', $term) ->orWhere('phone', 'like', $term) ->orWhere('mobile', 'like', $term) ->orWhere('email', 'like', $term); }); } // Filter by status (single ID or comma-separated list) if ($request->filled('lead_status_id')) { $ids = array_filter(explode(',', $request->get('lead_status_id'))); $query->whereIn('lead_status_id', $ids); } // Filter by source if ($request->filled('lead_source_id')) { $ids = array_filter(explode(',', $request->get('lead_source_id'))); $query->whereIn('lead_source_id', $ids); } // Filter by industry / category if ($request->filled('lead_industry_id')) { $ids = array_filter(explode(',', $request->get('lead_industry_id'))); $query->whereIn('lead_industry_id', $ids); } // Filter by rating if ($request->filled('lead_rating_id')) { $ids = array_filter(explode(',', $request->get('lead_rating_id'))); $query->whereIn('lead_rating_id', $ids); } // Partial filters on individual fields if ($request->filled('email')) { $query->where('email', 'like', '%' . $request->get('email') . '%'); } if ($request->filled('phone')) { $query->where('phone', 'like', '%' . $request->get('phone') . '%'); } if ($request->filled('mobile')) { $query->where('mobile', 'like', '%' . $request->get('mobile') . '%'); } if ($request->filled('company_name')) { $query->where('company_name', 'like', '%' . $request->get('company_name') . '%'); } if ($request->filled('job_title')) { $query->where('job_title', 'like', '%' . $request->get('job_title') . '%'); } // Date range filters if ($request->filled('created_from')) { $query->whereDate('created_at', '>=', $request->get('created_from')); } if ($request->filled('created_to')) { $query->whereDate('created_at', '<=', $request->get('created_to')); } $perPage = $request->get('per_page', self::DEFAULT_PER_PAGE); // Keep the API responsive: never load an unbounded amount of leads // into a single response (the app tables hold 100k+ leads). $perPage = max(1, min((int) $perPage, self::MAX_PER_PAGE)); $paginated = $query->paginate($perPage); $filterKeys = [ 'search', 'lead_status_id', 'lead_source_id', 'lead_industry_id', 'lead_rating_id', 'email', 'phone', 'mobile', 'company_name', 'job_title', 'created_from', 'created_to' ]; $filtersApplied = collect($filterKeys)->contains(fn($k) => $request->filled($k)); if ($paginated->total() === 0 && $filtersApplied) { return $this->respond(true, [['data' => [], 'total' => 0]], [], ['message' => 'No records match the applied filters.']); } // Build full records with index_fields appended as supplementary data $fieldSettingsService = app(LeadFieldSettingsService::class); $user = \Auth::user(); $apiIndexFields = $fieldSettingsService->getApiIndexFields($user); $paginatedArray = $paginated->toArray(); $paginatedArray['data'] = array_map(function ($item) use ($apiIndexFields, $paginated) { $lead = $paginated->getCollection()->firstWhere('id', $item['id']); $indexFields = []; if ($lead) { foreach ($apiIndexFields as $fieldDef) { $indexFields[] = [ 'key' => $fieldDef['key'], 'label' => $fieldDef['label'], 'value' => $this->resolveFieldValue($lead, $fieldDef), ]; } } $record = $lead ? $this->prepareLeadForResponse($lead) : ['id' => $item['id']]; $record['index_fields'] = $indexFields; return $record; }, $paginatedArray['data']); return $this->respond(true, [$paginatedArray], [], ['message' => trans('core::core.entity.records_found')]); } /** * Resolve the display value of a field for the API index. */ private function resolveFieldValue($lead, array $fieldDef) { $key = $fieldDef['key']; // Custom fields — use real column if provisioned, otherwise fall back to JSON if (!empty($fieldDef['is_custom'])) { if (!empty($fieldDef['uses_column'])) { return $lead->{$key} ?? null; } $custom = is_array($lead->custom_fields) ? $lead->custom_fields : []; return $custom[$key] ?? null; } // Special relation fields — return the related name $relationMap = [ 'lead_status_id' => ['leadStatus', 'name'], 'lead_source_id' => ['leadSource', 'name'], 'lead_industry_id' => ['leadIndustry', 'name'], 'lead_rating_id' => ['leadRating', 'name'], 'addr_country_id' => ['addrCountry', 'name'], 'language_id' => ['language', 'name'], 'referral_id' => ['referral', 'full_name'], ]; if (isset($relationMap[$key])) { [$relation, $column] = $relationMap[$key]; return optional($lead->{$relation})->{$column}; } // Regular attribute return $lead->{$key} ?? null; } /** * Return field definitions for data-driven mobile UI. * * Uses the same rich metadata contract as get/{id} but without value/display_value. * * @return \Illuminate\Http\JsonResponse */ public function fields() { $this->checkCompanyContext(); $user = \Auth::user(); $companyId = $user->company_id; $fieldSettingsService = app(LeadFieldSettingsService::class); $visibleFields = $fieldSettingsService->getVisibleFieldsForApi($user); $validationRules = $fieldSettingsService->getValidationRules($user); $optionsMap = $this->getLeadFieldOptionsMap($companyId); $rules = $this->buildLeadFieldRules($visibleFields, $optionsMap); $fieldEntries = []; $fieldMap = []; foreach ($visibleFields as $field) { $key = $field['key']; $validationKey = $field['validation_key'] ?? $key; $validation = $this->normalizeValidationRules( $validationRules[$validationKey] ?? Arr::get($validationRules, $key) ); $apiType = $this->mapApiFieldType($field); $metadata = [ 'key' => $key, 'label' => $field['label'], 'type' => $apiType, 'input_type' => $field['type'] ?? $apiType, 'required' => (bool) $field['required'], 'nullable' => !$field['required'], 'visible' => true, 'is_custom' => (bool) ($field['is_custom'] ?? false), 'panel' => $field['panel'] ?? null, 'order' => $field['order'] ?? null, 'special' => $field['special'] ?? null, 'max_length' => $field['max_length'] ?? null, 'validation' => $validation, ]; if (!empty($field['depends_on'])) { $metadata['depends_on'] = $field['depends_on']; $metadata['depends_map'] = $field['depends_map'] ?? []; } if (array_key_exists($key, $optionsMap)) { $metadata['options'] = $optionsMap[$key]; } if (!array_key_exists($key, $optionsMap) && ($field['type'] ?? null) === 'select') { $customOptions = $this->normalizeCustomSelectOptions($field['options'] ?? []); if (!empty($customOptions)) { $metadata['options'] = $customOptions; } } $fieldEntries[] = $metadata; $fieldMap[$metadata['key']] = $metadata; } return $this->respond(true, [ 'fields' => $fieldEntries, 'field_map' => $fieldMap, 'rules' => $rules, ], [], ['message' => 'fields fetched successfully']); } /** * Map a CRM settings field type to a user-friendly API type. */ private function mapApiFieldType(array $field) { if ($field['special'] !== null) { // Special relation/select fields return 'select'; } return $field['type'] ?? 'text'; } /** * Build detail metadata for a single visible field. */ private function buildLeadFieldMetadata(Lead $lead, array $field, array $validationRules, array $optionsMap) { $key = $field['key']; $validationKey = $field['validation_key'] ?? $key; $validation = $this->normalizeValidationRules( $validationRules[$validationKey] ?? Arr::get($validationRules, $key) ); $apiType = $this->mapApiFieldType($field); $value = $this->resolveLeadDetailFieldValue($lead, $field); $metadata = [ 'key' => $key, 'label' => $field['label'], 'type' => $apiType, 'input_type' => $field['type'] ?? $apiType, 'required' => (bool) $field['required'], 'nullable' => !$field['required'], 'visible' => (bool) ($field['visible'] ?? true), 'is_custom' => (bool) ($field['is_custom'] ?? false), 'panel' => $field['panel'] ?? null, 'order' => $field['order'] ?? null, 'special' => $field['special'] ?? null, 'max_length' => $field['max_length'] ?? null, 'value' => $value, 'display_value' => $this->resolveLeadDisplayValue($lead, $field, $value, $optionsMap), 'validation' => $validation, ]; if (!empty($field['depends_on'])) { $metadata['depends_on'] = $field['depends_on']; $metadata['depends_map'] = $field['depends_map'] ?? []; } if (array_key_exists($key, $optionsMap)) { $metadata['options'] = $optionsMap[$key]; } if (!array_key_exists($key, $optionsMap) && ($field['type'] ?? null) === 'select') { $customOptions = $this->normalizeCustomSelectOptions($field['options'] ?? []); if (!empty($customOptions)) { $metadata['options'] = $customOptions; } } return $metadata; } /** * Build grouped dependency rules for API clients. */ private function buildLeadFieldRules(array $fields, array $optionsMap): array { $fieldMap = []; foreach ($fields as $field) { $fieldMap[$field['key']] = $field; } $rules = []; foreach ($fields as $field) { $dependsOn = trim((string) ($field['depends_on'] ?? '')); if ($dependsOn === '') { continue; } $parentField = $fieldMap[$dependsOn] ?? ['key' => $dependsOn, 'label' => $dependsOn]; $rules[] = [ 'field' => $field['key'], 'field_label' => $field['label'], 'when_field' => $dependsOn, 'when_field_label' => $parentField['label'] ?? $dependsOn, 'conditions' => $this->buildLeadRuleConditions( is_array($field['depends_map'] ?? null) ? $field['depends_map'] : [], $this->getLeadRuleOptions($parentField, $optionsMap), $this->getLeadRuleOptions($field, $optionsMap) ), ]; } return $rules; } private function buildLeadRuleConditions(array $dependsMap, array $parentOptions, array $childOptions): array { $conditions = []; foreach ($dependsMap as $parentValue => $allowedValues) { if (!is_array($allowedValues)) { continue; } $conditions[] = [ 'value' => (string) $parentValue, 'display_value' => $this->resolveLeadRuleDisplayValue($parentValue, $parentOptions), 'options' => $this->filterLeadRuleOptions($allowedValues, $childOptions), ]; } return array_values($conditions); } private function getLeadRuleOptions(array $field, array $optionsMap): array { $key = $field['key'] ?? null; if ($key !== null && array_key_exists($key, $optionsMap)) { return array_values($optionsMap[$key]); } if (($field['type'] ?? null) === 'select') { return $this->normalizeCustomSelectOptions($field['options'] ?? []); } return []; } private function filterLeadRuleOptions(array $allowedValues, array $availableOptions): array { $lookup = []; foreach ($availableOptions as $option) { $optionId = Arr::get($option, 'id', Arr::get($option, 'value')); if ($optionId === null || $optionId === '') { continue; } $lookup[(string) $optionId] = [ 'id' => $optionId, 'name' => Arr::get($option, 'name', Arr::get($option, 'label', (string) $optionId)), ]; } $result = []; $seen = []; foreach ($allowedValues as $allowedValue) { $allowedKey = trim((string) $allowedValue); if ($allowedKey === '' || isset($seen[$allowedKey])) { continue; } $seen[$allowedKey] = true; $result[] = $lookup[$allowedKey] ?? ['id' => $allowedKey, 'name' => $allowedKey]; } return $result; } private function resolveLeadRuleDisplayValue($value, array $availableOptions): string { $valueKey = (string) $value; foreach ($availableOptions as $option) { $optionId = Arr::get($option, 'id', Arr::get($option, 'value')); if ((string) $optionId === $valueKey) { return (string) (Arr::get($option, 'name', Arr::get($option, 'label', $valueKey)) ?? $valueKey); } } return $valueKey; } /** * Resolve the raw field value for the detailed endpoint. */ private function resolveLeadDetailFieldValue(Lead $lead, array $field) { $key = $field['key']; if (!empty($field['is_custom'])) { if (!empty($field['uses_column'])) { return $lead->{$key} ?? null; } $custom = is_array($lead->custom_fields) ? $lead->custom_fields : []; return $custom[$key] ?? null; } if (($field['special'] ?? null) === 'multiselect_choice') { return $lead->relationLoaded('multiSelects') ? $lead->multiSelects->pluck('id')->values()->all() : $lead->multiSelects()->pluck('leads_multi_select.id')->values()->all(); } if ($key === 'owned_by') { return $lead->owned_by_type . ':' . $lead->owned_by_id; } return $lead->{$key} ?? null; } /** * Resolve a display-friendly field value. */ private function resolveLeadDisplayValue(Lead $lead, array $field, $value, array $optionsMap) { $key = $field['key']; $special = $field['special'] ?? null; if (!empty($field['is_custom'])) { return $value; } if ($special === 'multiselect_choice') { $selected = collect($optionsMap[$key] ?? [])->keyBy('id'); return collect((array) $value) ->map(function ($id) use ($selected) { return $selected[$id] ?? ['id' => $id, 'name' => null]; }) ->values() ->all(); } $relationMap = [ 'lead_status_id' => ['leadStatus', 'name'], 'lead_source_id' => ['leadSource', 'name'], 'lead_industry_id' => ['leadIndustry', 'name'], 'lead_rating_id' => ['leadRating', 'name'], 'addr_country_id' => ['addrCountry', 'name'], 'language_id' => ['language', 'name'], 'referral_id' => ['referral', 'full_name'], 'owned_by' => ['ownedBy', 'name'], ]; if (isset($relationMap[$key])) { [$relation, $column] = $relationMap[$key]; return optional($lead->{$relation})->{$column}; } return $value; } /** * Normalize request/service rules to an array. */ private function normalizeValidationRules($rules) { if (is_string($rules)) { return array_values(array_filter(explode('|', $rules))); } if (is_array($rules)) { return array_values($rules); } return []; } /** * Build reusable option lists for selectable lead fields. */ private function getLeadFieldOptionsMap($companyId) { return [ 'lead_status_id' => LeadStatus::where('company_id', $companyId)->get(['id', 'name'])->toArray(), 'lead_source_id' => LeadSource::where('company_id', $companyId)->get(['id', 'name'])->toArray(), 'lead_industry_id' => LeadIndustry::where('company_id', $companyId)->get(['id', 'name'])->toArray(), 'lead_rating_id' => LeadRating::where('company_id', $companyId)->get(['id', 'name'])->toArray(), 'addr_country_id' => Country::where('company_id', $companyId)->whereIsActive(1)->get(['id', 'name'])->toArray(), 'language_id' => CustomerLanguage::where('company_id', $companyId)->get(['id', 'name'])->toArray(), 'referral_id' => Lead::where('company_id', $companyId)->get(['id', 'full_name as name'])->toArray(), 'owned_by' => $this->normalizeChoiceMap(FormHelper::assignedToChoises()), 'multiSelects' => LeadMultiSelect::where('company_id', $companyId)->get(['id', 'name'])->toArray(), ]; } private function normalizeCustomSelectOptions($options): array { $raw = []; if (is_string($options)) { $raw = preg_split('/[\r\n,]+/', $options) ?: []; } elseif (is_array($options)) { $raw = $options; } $result = []; foreach ($raw as $item) { $value = trim((string) $item); if ($value === '') { continue; } $result[$value] = ['id' => $value, 'name' => $value]; } return array_values($result); } /** * Normalize key/value choices to an array of option objects. */ private function normalizeChoiceMap(array $choices) { $result = []; foreach ($choices as $id => $name) { $result[] = [ 'id' => (string) $id, 'name' => $name, ]; } return $result; } public function allStatuses() { $choices = LeadStatus::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } public function allSources() { $choices = LeadSource::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } public function allIndustries() { $choices = LeadIndustry::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } public function allRatings() { $choices = LeadRating::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } }