diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 4fb808e128..ea0bbb16f7 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -19,6 +19,7 @@ use App\Models\Invoice; use App\Models\Project; use Elastic\Elasticsearch\ClientBuilder; use App\Http\Requests\Search\GenericSearchRequest; +use Illuminate\Support\Str; class SearchController extends Controller { @@ -44,6 +45,8 @@ class SearchController extends Controller private array $projects = []; + private array $tasks = []; + public function __invoke(GenericSearchRequest $request) { if (config('scout.driver') == 'elastic') { @@ -87,17 +90,41 @@ class SearchController extends Controller $params = [ // 'index' => 'clients,invoices,client_contacts', // 'index' => 'clients,invoices,client_contacts,quotes,expenses,credits,recurring_invoices,vendors,vendor_contacts,purchase_orders,projects', - 'index' => 'clients_v2,invoices_v2,client_contacts_v2,quotes_v2,expenses_v2,credits_v2,recurring_invoices_v2,vendors_v2,vendor_contacts_v2,purchase_orders_v2,projects_v2', - 'body' => [ + 'index' => 'clients_v2,invoices_v2,client_contacts_v2,quotes_v2,expenses_v2,credits_v2,recurring_invoices_v2,vendors_v2,vendor_contacts_v2,purchase_orders_v2,projects_v2,tasks_v2', + 'body' => [ 'query' => [ 'bool' => [ - 'must' => [ - 'multi_match' => [ - 'query' => $search, - 'fields' => ['*'], - 'fuzziness' => 'AUTO', + 'should' => [ + [ + 'multi_match' => [ + 'query' => $search, + 'fields' => ['*'], + 'fuzziness' => 'AUTO', + ] + ], + // Safe nested search that won't fail on missing fields + [ + 'nested' => [ + 'path' => 'line_items', + 'query' => [ + 'multi_match' => [ + 'query' => $search, + 'fields' => [ + 'line_items.product_key^2', + 'line_items.notes^2', + 'line_items.custom_value1', + 'line_items.custom_value2', + 'line_items.custom_value3', + 'line_items.custom_value4' + ], + 'fuzziness' => 'AUTO', + ] + ], + 'ignore_unmapped' => true + ] ], ], + 'minimum_should_match' => 1, 'filter' => [ 'match' => [ 'company_key' => $company->company_key, @@ -109,8 +136,11 @@ class SearchController extends Controller ], ]; + $results = $elastic->search($params); + nlog($results['hits']); + $this->mapResults($results['hits']['hits'] ?? []); return response()->json([ @@ -125,6 +155,7 @@ class SearchController extends Controller 'vendor_contacts' => $this->vendor_contacts, 'purchase_orders' => $this->purchase_orders, 'projects' => $this->projects, + 'tasks' => $this->tasks, 'settings' => $this->settingsMap(), ], 200); @@ -134,8 +165,8 @@ class SearchController extends Controller { foreach ($results as $result) { - switch ($result['_index']) { - case 'clients': + switch (true) { + case Str::startsWith($result['_index'], 'clients'): if ($result['_source']['is_deleted']) { //do not return deleted results break; @@ -149,7 +180,7 @@ class SearchController extends Controller ]; break; - case 'invoices': + case Str::startsWith($result['_index'], 'invoices'): if ($result['_source']['is_deleted']) { //do not return deleted invoices break; @@ -163,7 +194,7 @@ class SearchController extends Controller 'path' => "/invoices/{$result['_source']['hashed_id']}/edit" ]; break; - case 'client_contacts': + case Str::startsWith($result['_index'], 'client_contacts'): if ($result['_source']['__soft_deleted']) { break; @@ -176,7 +207,7 @@ class SearchController extends Controller 'path' => "/clients/{$result['_source']['client_id']}" ]; break; - case 'quotes': + case Str::startsWith($result['_index'], 'quotes'): if ($result['_source']['__soft_deleted']) { break; @@ -191,7 +222,7 @@ class SearchController extends Controller break; - case 'expenses': + case Str::startsWith($result['_index'], 'expenses'): if ($result['_source']['__soft_deleted']) { break; @@ -206,7 +237,7 @@ class SearchController extends Controller break; - case 'credits': + case Str::startsWith($result['_index'], 'credits'): if ($result['_source']['__soft_deleted']) { break; @@ -221,7 +252,7 @@ class SearchController extends Controller break; - case 'recurring_invoices': + case Str::startsWith($result['_index'], 'recurring_invoices'): if ($result['_source']['__soft_deleted']) { break; @@ -236,7 +267,7 @@ class SearchController extends Controller break; - case 'vendors': + case Str::startsWith($result['_index'], 'vendors'): if ($result['_source']['__soft_deleted']) { break; @@ -251,7 +282,7 @@ class SearchController extends Controller break; - case 'vendor_contacts': + case Str::startsWith($result['_index'], 'vendor_contacts'): if ($result['_source']['__soft_deleted']) { break; @@ -266,7 +297,7 @@ class SearchController extends Controller break; - case 'purchase_orders': + case Str::startsWith($result['_index'], 'purchase_orders'): if ($result['_source']['__soft_deleted']) { break; @@ -281,7 +312,7 @@ class SearchController extends Controller break; - case 'projects': + case Str::startsWith($result['_index'], 'projects'): if ($result['_source']['__soft_deleted']) { break; @@ -294,6 +325,20 @@ class SearchController extends Controller 'path' => "/projects/{$result['_source']['hashed_id']}" ]; + break; + case Str::startsWith($result['_index'], 'tasks'): + + if ($result['_source']['is_deleted']) { + break; + } + + $this->tasks[] = [ + 'name' => $result['_source']['name'], + 'type' => '/task', + 'id' => $result['_source']['hashed_id'], + 'path' => "/tasks/{$result['_source']['hashed_id']}/edit" + ]; + break; } } diff --git a/app/Models/Client.php b/app/Models/Client.php index c764952268..5dc8ab2d37 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -267,9 +267,9 @@ class Client extends BaseModel implements HasLocalePreference return [ 'id' => $this->company->db.":".$this->id, 'name' => $name, - 'is_deleted' => $this->is_deleted, + 'is_deleted' => (bool)$this->is_deleted, 'hashed_id' => $this->hashed_id, - 'number' => $this->number, + 'number' => (string)$this->number, 'id_number' => $this->id_number, 'vat_number' => $this->vat_number, 'balance' => $this->balance, diff --git a/app/Models/ClientContact.php b/app/Models/ClientContact.php index e581ffc704..bce59cd107 100644 --- a/app/Models/ClientContact.php +++ b/app/Models/ClientContact.php @@ -169,6 +169,11 @@ class ClientContact extends Authenticatable implements HasLocalePreference 'email', ]; + public function searchableAs(): string + { + return 'client_contacts_v2'; + } + public function toSearchableArray() { return [ diff --git a/app/Models/Credit.php b/app/Models/Credit.php index 8d7f95408b..4f9c4fd962 100644 --- a/app/Models/Credit.php +++ b/app/Models/Credit.php @@ -122,7 +122,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \App\Models\Client $client * @property \App\Models\Vendor|null $vendor * @property-read \App\Models\Location|null $location - * @property-read mixed $pivot + * @property-read mixed $pivotcredi * @property-read \Illuminate\Database\Eloquent\Collection $activities * @property-read \Illuminate\Database\Eloquent\Collection $company_ledger * @property-read \Illuminate\Database\Eloquent\Collection $documents @@ -223,8 +223,8 @@ class Credit extends BaseModel 'id' => $this->company->db.":".$this->id, 'name' => ctrans('texts.credit') . " " . $this->number . " | " . $this->client->present()->name() . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'amount' => (float) $this->amount, 'balance' => (float) $this->balance, 'due_date' => $this->due_date, diff --git a/app/Models/Expense.php b/app/Models/Expense.php index 5f4edab67d..e6066215f8 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -187,8 +187,8 @@ class Expense extends BaseModel 'id' => $this->company->db.":".$this->id, 'name' => ctrans('texts.expense') . " " . ($this->number ?? '') . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'amount' => (float) $this->amount, 'date' => $this->date ?? null, 'custom_value1' => (string)$this->custom_value1, @@ -196,6 +196,8 @@ class Expense extends BaseModel 'custom_value3' => (string)$this->custom_value3, 'custom_value4' => (string)$this->custom_value4, 'company_key' => $this->company->company_key, + 'public_notes' => (string)$this->public_notes, + 'private_notes' => (string)$this->private_notes ]; } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index f14c325143..2cbccd0bf1 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -269,8 +269,8 @@ class Invoice extends BaseModel 'id' => (string)$this->company->db.":".$this->id, 'name' => ctrans('texts.invoice') . " " . $this->number . " | " . $this->client->present()->name() . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'amount' => (float) $this->amount, 'balance' => (float) $this->balance, 'due_date' => $this->due_date, diff --git a/app/Models/Project.php b/app/Models/Project.php index 1f7baf1435..0c4bf08a49 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -116,8 +116,8 @@ class Project extends BaseModel 'id' => (string)$this->company->db.":".$this->id, 'name' => ctrans('texts.project') . " " . $this->number . ' | ' . $this->name . " | " . $this->client->present()->name(), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'task_rate' => (float) $this->task_rate, 'budgeted_hours' => (float) $this->budgeted_hours, 'due_date' => $this->due_date, diff --git a/app/Models/PurchaseOrder.php b/app/Models/PurchaseOrder.php index c4cbd815d7..7674bf6abf 100644 --- a/app/Models/PurchaseOrder.php +++ b/app/Models/PurchaseOrder.php @@ -228,8 +228,8 @@ class PurchaseOrder extends BaseModel 'id' => $this->company->db.":".$this->id, 'name' => ctrans('texts.purchase_order') . " " . $this->number . " | " . $this->vendor->present()->name() . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'amount' => (float) $this->amount, 'balance' => (float) $this->balance, 'due_date' => $this->due_date, diff --git a/app/Models/Quote.php b/app/Models/Quote.php index c3026ea821..d512cdb483 100644 --- a/app/Models/Quote.php +++ b/app/Models/Quote.php @@ -220,8 +220,8 @@ class Quote extends BaseModel 'id' => $this->company->db.":".$this->id, 'name' => ctrans('texts.quote') . " " . ($this->number ?? '') . " | " . $this->client->present()->name() . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'amount' => (float) $this->amount, 'balance' => (float) $this->balance, 'due_date' => $this->due_date, diff --git a/app/Models/RecurringInvoice.php b/app/Models/RecurringInvoice.php index f03f852e97..f37705ba7e 100644 --- a/app/Models/RecurringInvoice.php +++ b/app/Models/RecurringInvoice.php @@ -138,15 +138,6 @@ class RecurringInvoice extends BaseModel use PresentableTrait; use Searchable; - /** - * Get the index name for the model. - * - * @return string - */ - public function searchableAs(): string - { - return 'recurring_invoices_v2'; - } protected $presenter = RecurringInvoicePresenter::class; @@ -280,17 +271,65 @@ class RecurringInvoice extends BaseModel 'remaining_cycles', ]; + + /** + * Get the index name for the model. + * + * @return string + */ + public function searchableAs(): string + { + return 'recurring_invoices_v2'; + } + public function toSearchableArray() { $locale = $this->company->locale(); App::setLocale($locale); + // Properly cast line items to ensure correct types + $line_items = []; + if ($this->line_items) { + foreach ($this->line_items as $item) { + $line_items[] = [ + 'quantity' => (float)($item->quantity ?? 0), + 'net_cost' => (float)($item->net_cost ?? 0), + 'cost' => (float)($item->cost ?? 0), + 'product_key' => (string)($item->product_key ?? ''), + 'product_cost' => (float)($item->product_cost ?? 0), + 'notes' => (string)($item->notes ?? ''), + 'discount' => (float)($item->discount ?? 0), + 'is_amount_discount' => (bool)($item->is_amount_discount ?? false), + 'tax_name1' => (string)($item->tax_name1 ?? ''), + 'tax_rate1' => (float)($item->tax_rate1 ?? 0), + 'tax_name2' => (string)($item->tax_name2 ?? ''), + 'tax_rate2' => (float)($item->tax_rate2 ?? 0), + 'tax_name3' => (string)($item->tax_name3 ?? ''), + 'tax_rate3' => (float)($item->tax_rate3 ?? 0), + 'sort_id' => (string)($item->sort_id ?? ''), + 'line_total' => (float)($item->line_total ?? 0), + 'gross_line_total' => (float)($item->gross_line_total ?? 0), + 'tax_amount' => (float)($item->tax_amount ?? 0), + 'date' => (string)($item->date ?? ''), + 'custom_value1' => (string)($item->custom_value1 ?? ''), + 'custom_value2' => (string)($item->custom_value2 ?? ''), + 'custom_value3' => (string)($item->custom_value3 ?? ''), + 'custom_value4' => (string)($item->custom_value4 ?? ''), + 'type_id' => (string)($item->type_id ?? ''), + 'tax_id' => (string)($item->tax_id ?? ''), + 'task_id' => (string)($item->task_id ?? ''), + 'expense_id' => (string)($item->expense_id ?? ''), + 'unit_code' => (string)($item->unit_code ?? ''), + ]; + } + } + return [ 'id' => $this->company->db.":".$this->id, 'name' => ctrans('texts.recurring_invoice') . " " . $this->number . " | " . $this->client->present()->name() . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale), 'hashed_id' => $this->hashed_id, - 'number' => $this->number, - 'is_deleted' => $this->is_deleted, + 'number' => (string)$this->number, + 'is_deleted' => (bool)$this->is_deleted, 'amount' => (float) $this->amount, 'balance' => (float) $this->balance, 'due_date' => $this->due_date, @@ -301,6 +340,7 @@ class RecurringInvoice extends BaseModel 'custom_value4' => (string)$this->custom_value4, 'company_key' => $this->company->company_key, 'po_number' => (string)$this->po_number, + 'line_items' => $line_items, ]; } diff --git a/app/Models/Task.php b/app/Models/Task.php index 9c51a51a24..88a468ab5f 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -109,6 +109,16 @@ class Task extends BaseModel use Filterable; use Searchable; + /** + * Get the index name for the model. + * + * @return string + */ + public function searchableAs(): string + { + return 'tasks_v2'; + } + protected $fillable = [ 'client_id', 'invoice_id', diff --git a/app/Models/Vendor.php b/app/Models/Vendor.php index 42c0fa0187..1c91dfd9dc 100644 --- a/app/Models/Vendor.php +++ b/app/Models/Vendor.php @@ -171,9 +171,9 @@ class Vendor extends BaseModel return [ 'id' => $this->company->db.":".$this->id, 'name' => $name, - 'is_deleted' => $this->is_deleted, + 'is_deleted' => (bool)$this->is_deleted, 'hashed_id' => $this->hashed_id, - 'number' => $this->number, + 'number' => (string)$this->number, 'id_number' => $this->id_number, 'vat_number' => $this->vat_number, 'phone' => $this->phone, diff --git a/app/Models/VendorContact.php b/app/Models/VendorContact.php index 43c572057d..fae348574f 100644 --- a/app/Models/VendorContact.php +++ b/app/Models/VendorContact.php @@ -126,20 +126,25 @@ class VendorContact extends Authenticatable implements HasLocalePreference 'send_email', ]; + public function searchableAs(): string + { + return 'vendor_contacts_v2'; + } + public function toSearchableArray() { return [ 'id' => $this->company->db.":".$this->id, 'name' => $this->present()->search_display(), 'hashed_id' => $this->hashed_id, - 'email' => $this->email, - 'first_name' => $this->first_name, - 'last_name' => $this->last_name, - 'phone' => $this->phone, - 'custom_value1' => $this->custom_value1, - 'custom_value2' => $this->custom_value2, - 'custom_value3' => $this->custom_value3, - 'custom_value4' => $this->custom_value4, + 'email' => (string)$this->email, + 'first_name' => (string)$this->first_name, + 'last_name' => (string)$this->last_name, + 'phone' => (string)$this->phone, + 'custom_value1' => (string)$this->custom_value1, + 'custom_value2' => (string)$this->custom_value2, + 'custom_value3' => (string)$this->custom_value3, + 'custom_value4' => (string)$this->custom_value4, 'company_key' => $this->company->company_key, 'vendor_id' => $this->vendor->hashed_id, ]; diff --git a/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php b/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php index 06af6bc3f5..53e5086ac4 100644 --- a/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php +++ b/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php @@ -13,6 +13,10 @@ final class CreateRecurringInvoicesIndex implements MigrationInterface */ public function up(): void { + // Force drop any existing indices to avoid mapping conflicts + Index::dropIfExists('recurring_invoices_v2'); + Index::dropIfExists('recurring_invoices'); + $mapping = [ 'properties' => [ // Core recurring invoice fields diff --git a/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php b/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php index cc12b50201..9a8de4da39 100644 --- a/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php +++ b/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php @@ -39,7 +39,6 @@ final class CreateVendorContactsIndex implements MigrationInterface 'company_key' => ['type' => 'keyword'], 'vendor_id' => ['type' => 'keyword'], 'send_email' => ['type' => 'boolean'], - 'last_login' => ['type' => 'date'], ] ];