'tickets.browse', 'create' => 'tickets.create', 'update' => 'tickets.update', 'destroy' => 'tickets.destroy', ]; protected $showRoute = 'tickets.tickets.show'; protected $storeRequest = TicketApiRequest::class; protected $updateRequest = UpdateTicketApiRequest::class; public function __construct() { parent::__construct(); } /** * List tickets with optional filters. * * Supported query parameters: * - search : searches ticket fields and related dictionary/entity names * - id : filter by ticket ID (int, array or comma-separated IDs) * - ticket_status_id : filter by status ID * - ticket_priority_id: filter by priority ID * - ticket_severity_id: filter by severity ID * - ticket_category_id: filter by category ID * - account_id : filter by account ID * - contact_id : filter by contact ID * - lead_id : filter by lead ID * - parent_id : filter by parent ticket ID * - ticket_owner_id : filter by ticket owner ID * - name : partial ticket name * - description : partial description * - resolution : partial resolution * - notes : partial notes * - due_time : partial due time * - due_date : exact due date (Y-m-d) * - due_date_from : due date on/after date (Y-m-d) * - due_date_to : due date on/before date (Y-m-d) * - created_from : created on/after date (Y-m-d) * - created_to : created on/before date (Y-m-d) * - updated_from : updated on/after date (Y-m-d) * - updated_to : updated on/before date (Y-m-d) * - status_name : partial status name * - priority_name : partial priority name * - severity_name : partial severity name * - category_name : partial category name * - account_name : partial account name * - contact_name : partial contact full name * - lead_name : partial lead full name * - parent_name : partial parent ticket name * - ticket_owner_name : partial ticket owner name * - sort_by : sortable columns (id, name, due_date, due_time, created_at, updated_at, * ticket_status_id, ticket_priority_id, ticket_severity_id, ticket_category_id, * ticket_owner_id) * - sort_dir : asc|desc * - 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); } if ($search = $request->get('search')) { $term = '%' . $search . '%'; $query->where(function ($q) use ($term) { $q->where('name', 'like', $term) ->orWhere('description', 'like', $term) ->orWhere('resolution', 'like', $term) ->orWhere('notes', 'like', $term) ->orWhere('due_time', 'like', $term) ->orWhereHas('ticketStatus', function ($related) use ($term) { $related->where('name', 'like', $term); }) ->orWhereHas('ticketPriority', function ($related) use ($term) { $related->where('name', 'like', $term); }) ->orWhereHas('ticketSeverity', function ($related) use ($term) { $related->where('name', 'like', $term); }) ->orWhereHas('ticketCategory', function ($related) use ($term) { $related->where('name', 'like', $term); }) ->orWhereHas('account', function ($related) use ($term) { $related->where('name', 'like', $term); }) ->orWhereHas('contact', function ($related) use ($term) { $related->where('full_name', 'like', $term); }) ->orWhereHas('lead', function ($related) use ($term) { $related->where('full_name', 'like', $term); }) ->orWhereHas('parent', function ($related) use ($term) { $related->where('name', 'like', $term); }) ->orWhereHas('ticketOwner', function ($related) use ($term) { $related->where('name', 'like', $term); }); }); } $applyIdFilter = function ($column, $parameter) use ($request, $query) { if (!$request->filled($parameter)) { return; } $rawIds = $request->get($parameter); if (!is_array($rawIds)) { $rawIds = explode(',', (string)$rawIds); } $ids = array_values(array_filter($rawIds, function ($id) { return $id !== null && $id !== ''; })); if (!empty($ids)) { $query->whereIn($column, $ids); } }; $applyIdFilter('id', 'id'); $applyIdFilter('ticket_status_id', 'ticket_status_id'); $applyIdFilter('ticket_priority_id', 'ticket_priority_id'); $applyIdFilter('ticket_severity_id', 'ticket_severity_id'); $applyIdFilter('ticket_category_id', 'ticket_category_id'); $applyIdFilter('account_id', 'account_id'); $applyIdFilter('contact_id', 'contact_id'); $applyIdFilter('lead_id', 'lead_id'); $applyIdFilter('parent_id', 'parent_id'); $applyIdFilter('ticket_owner_id', 'ticket_owner_id'); $likeFilters = [ 'name' => 'name', 'description' => 'description', 'resolution' => 'resolution', 'notes' => 'notes', 'due_time' => 'due_time', ]; foreach ($likeFilters as $parameter => $column) { if ($request->filled($parameter)) { $query->where($column, 'like', '%' . $request->get($parameter) . '%'); } } $relationLikeFilters = [ 'status_name' => ['relation' => 'ticketStatus', 'column' => 'name'], 'priority_name' => ['relation' => 'ticketPriority', 'column' => 'name'], 'severity_name' => ['relation' => 'ticketSeverity', 'column' => 'name'], 'category_name' => ['relation' => 'ticketCategory', 'column' => 'name'], 'account_name' => ['relation' => 'account', 'column' => 'name'], 'contact_name' => ['relation' => 'contact', 'column' => 'full_name'], 'lead_name' => ['relation' => 'lead', 'column' => 'full_name'], 'parent_name' => ['relation' => 'parent', 'column' => 'name'], 'ticket_owner_name' => ['relation' => 'ticketOwner', 'column' => 'name'], ]; foreach ($relationLikeFilters as $parameter => $filterConfig) { if ($request->filled($parameter)) { $term = '%' . $request->get($parameter) . '%'; $query->whereHas($filterConfig['relation'], function ($related) use ($filterConfig, $term) { $related->where($filterConfig['column'], 'like', $term); }); } } if ($request->filled('due_date')) { $query->whereDate('due_date', $request->get('due_date')); } if ($request->filled('due_date_from')) { $query->whereDate('due_date', '>=', $request->get('due_date_from')); } if ($request->filled('due_date_to')) { $query->whereDate('due_date', '<=', $request->get('due_date_to')); } 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')); } if ($request->filled('updated_from')) { $query->whereDate('updated_at', '>=', $request->get('updated_from')); } if ($request->filled('updated_to')) { $query->whereDate('updated_at', '<=', $request->get('updated_to')); } $sortableColumns = [ 'id', 'name', 'due_date', 'due_time', 'created_at', 'updated_at', 'ticket_status_id', 'ticket_priority_id', 'ticket_severity_id', 'ticket_category_id', 'ticket_owner_id', ]; if ($request->filled('sort_by') && in_array($request->get('sort_by'), $sortableColumns)) { $direction = strtolower((string)$request->get('sort_dir', 'desc')) === 'asc' ? 'asc' : 'desc'; $query->orderBy($request->get('sort_by'), $direction); } else { $query->orderBy('created_at', 'desc'); } $perPage = (int)$request->get('per_page', self::DEFAULT_PER_PAGE); if ($perPage <= 0) { $perPage = self::DEFAULT_PER_PAGE; } $paginated = $query->paginate($perPage); $filterKeys = [ 'search', 'id', 'ticket_status_id', 'ticket_priority_id', 'ticket_severity_id', 'ticket_category_id', 'account_id', 'contact_id', 'lead_id', 'parent_id', 'ticket_owner_id', 'name', 'description', 'resolution', 'notes', 'due_time', 'due_date', 'due_date_from', 'due_date_to', 'created_from', 'created_to', 'updated_from', 'updated_to', 'status_name', 'priority_name', 'severity_name', 'category_name', 'account_name', 'contact_name', 'lead_name', 'parent_name', 'ticket_owner_name', ]; $filtersApplied = collect($filterKeys)->contains(function ($key) use ($request) { return $request->filled($key); }); if ($paginated->total() === 0 && $filtersApplied) { return $this->respond(true, [['data' => [], 'total' => 0]], [], ['message' => 'No records match the applied filters.']); } // Append API index fields from CRM settings $user = \Auth::user(); $service = app(TicketFieldSettingsService::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->prepareTicketForResponse($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 with field metadata ──────────────────────────────────── 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 = Ticket::with($this->with)->find($identifier); if (!$entity) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } $user = \Auth::user(); $service = app(TicketFieldSettingsService::class); $fields = $service->getVisibleFieldsForApi($user); $validationRules = $service->getValidationRules($user); $optionsMap = $this->buildTicketOptionsMap(); $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, [ 'ticket' => $this->prepareTicketForResponse($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(TicketFieldSettingsService::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 Ticket())->getFillable(), array_merge(['custom_data'], $customKeys)); $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 = Ticket::create($data); $entity = $this->setupAssignedTo($entity, $request->all(), true); // The created entry is already logged automatically by LogsActivity during // Ticket::create(). Suppress this bookkeeping save so owner assignment right // after creation does not add a second 'updated' activity entry. $entity->disableLogging(); $entity->save(); $entity->enableLogging(); $entity->load($this->with); return $this->respond(true, $this->prepareTicketForResponse($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 = Ticket::find($identifier); if (!$entity) { return $this->respond(false, [], ['error' => 'entity_not_found'], ['message' => trans('core::core.entity.entity_not_found')]); } $svc = app(TicketFieldSettingsService::class); $customKeys = array_column( $svc->getVisibleCustomFieldsForDatatable(\Auth::user()), 'key' ); $builtInFillable = array_diff((new Ticket())->getFillable(), array_merge(['custom_data'], $customKeys)); $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 = $this->setupAssignedTo($entity, $request->all()); $entity->update($data); $entity->load($this->with); return $this->respond(true, $this->prepareTicketForResponse($entity), [], ['message' => trans('core::core.entity.updated')]); } // ── Field schema driven by CRM field settings ───────────────────────────── /** * Return the complete field schema for the Tickets module. * * Response structure mirrors GET /api/leads/fields and GET /api/calllogs/fields * so API clients can use a single data-driven form-building strategy across * all CRM modules. The schema automatically reflects any changes made in * CRM Settings → Ticket Fields. */ public function fields() { $this->checkCompanyContext(); $user = \Auth::user(); $service = app(TicketFieldSettingsService::class); $visibleFields = $service->getVisibleFieldsForApi($user); $validationRules = $service->getValidationRules($user); $optionsMap = $this->buildTicketOptionsMap(); $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']); } // ── Response preparation ────────────────────────────────────────────────── /** * Flatten custom_data into top-level keys, compute owned_by string, * and strip internal Eloquent morph columns. * Consumers receive a uniform object with no distinction between * built-in and custom fields, and no raw JSON blobs. */ private function prepareTicketForResponse(Ticket $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 ticket was created before the field was added. $customFields = app(TicketFieldSettingsService::class) ->getVisibleCustomFieldsForDatatable(\Auth::user()); foreach ($customFields as $field) { $data[$field['key']] = $storedCustom[$field['key']] ?? null; } // Compute the human-readable owned_by string ("user-{id}" / "group-{id}") if (!empty($entity->owned_by_id) && !empty($entity->owned_by_type)) { $ownerType = class_basename($entity->owned_by_type); $prefix = (strtolower($ownerType) === 'group') ? 'group' : 'user'; $data['owned_by'] = $prefix . '-' . $entity->owned_by_id; } else { $data['owned_by'] = null; } // Remove raw morph internals and soft-delete timestamp unset($data['owned_by_type'], $data['owned_by_id'], $data['deleted_at']); return $data; } // ── Value resolution (required by CrmFieldApiTrait) ─────────────────────── protected function resolveApiFieldValue(object $entity, array $field): mixed { $key = $field['key']; if ($field['is_custom'] ?? false) { $custom = is_array($entity->custom_data) ? $entity->custom_data : []; return $custom[$key] ?? null; } if ($key === 'owned_by') { if ($entity->owned_by_id && $entity->owned_by_type) { $prefix = (strtolower(class_basename($entity->owned_by_type)) === 'group') ? 'group' : 'user'; return $prefix . '-' . $entity->owned_by_id; } return null; } return $entity->{$key} ?? null; } protected function resolveApiDisplayValue(object $entity, array $field, mixed $value, array $optionsMap): mixed { $key = $field['key']; if ($field['is_custom'] ?? false) { return $value; } $relationMap = [ 'ticket_status_id' => ['ticketStatus', 'name'], 'ticket_priority_id' => ['ticketPriority', 'name'], 'ticket_severity_id' => ['ticketSeverity', 'name'], 'ticket_category_id' => ['ticketCategory', 'name'], 'lead_id' => ['lead', 'full_name'], 'contact_id' => ['contact', 'full_name'], 'account_id' => ['account', 'name'], 'parent_id' => ['parent', 'name'], 'owned_by' => ['ownedBy', 'name'], 'ticket_owner_id' => ['ticketOwner', 'name'], ]; if (isset($relationMap[$key])) { [$relation, $column] = $relationMap[$key]; return optional($entity->{$relation})->{$column}; } return $value; } // ── Options map ─────────────────────────────────────────────────────────── private function buildTicketOptionsMap(): array { $companyId = \Auth::user()->company_id; return [ 'ticket_status_id' => TicketStatus::where('company_id', $companyId)->get(['id', 'name'])->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'ticket_priority_id' => TicketPriority::where('company_id', $companyId)->get(['id', 'name'])->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'ticket_severity_id' => TicketSeverity::where('company_id', $companyId)->get(['id', 'name'])->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'ticket_category_id' => TicketCategory::where('company_id', $companyId)->get(['id', 'name'])->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'lead_id' => Lead::where('company_id', $companyId)->select('id', 'full_name as name')->get()->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'contact_id' => Contact::where('company_id', $companyId)->select('id', 'full_name as name')->get()->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'account_id' => Account::where('company_id', $companyId)->get(['id', 'name'])->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'parent_id' => Ticket::where('company_id', $companyId)->get(['id', 'name'])->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), 'owned_by' => $this->normalizeApiChoiceMap(FormHelper::assignedToChoises()), 'ticket_owner_id' => User::select('id', 'name')->get()->map(fn($r) => ['id' => $r->id, 'name' => $r->name])->toArray(), ]; } public function allStatuses() { $choices = TicketStatus::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } public function allPriorities() { $choices = TicketPriority::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } public function allSeverities() { $choices = TicketSeverity::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } public function allCategories() { $choices = TicketCategory::where('company_id', \Auth::user()->company_id) ->pluck('name', 'id') ->toArray(); return $this->respond(true, ['user' => $choices], [], 'data fetched successfully'); } }