# Global Search

A keyboard-driven ⌘K-style search wired to the existing sidebar input. Returns results grouped by resource type (Candidates, Posts, Applications, Exam Attempts, Questions, Users, Pages) and is **role-aware at the query level** — no role can ever receive a row it isn't authorised to see.

## Endpoint

```
GET /search?q=<term>
Auth:     auth middleware (required)
Throttle: configurable in config/global_search.php (default 60/min per user+IP)
Returns:  application/json
```

```json
{
  "q": "kha",
  "took_ms": 14,
  "total": 11,
  "groups": [
    {
      "key": "candidates",
      "label": "Candidates",
      "icon": "users",
      "count": 3,
      "items": [
        { "id": "candidate:42", "title": "Khalid Afridi",
          "subtitle": "CNIC •••••••••••6789 · Approved",
          "url": "/admin/candidates/42",
          "icon": "users", "badge": null }
      ]
    }
  ]
}
```

## Architecture

```
app/Services/GlobalSearch/
├── Contracts/Searchable.php     interface every resource implements
├── Result.php                    immutable DTO (id, title, subtitle, url, group, icon, badge)
├── GlobalSearchService.php       orchestrator — instantiates Searchables from config,
│                                 filters by role, fans out, caches, caps
└── Resources/
    ├── PageSearchable.php        static route shortcuts (no DB hit)
    ├── CandidateSearchable.php
    ├── ApplicationSearchable.php
    ├── ExamAttemptSearchable.php
    ├── PostSearchable.php
    ├── QuestionSearchable.php
    └── UserSearchable.php
```

The HTTP layer is one controller (`App\Http\Controllers\GlobalSearchController`) and one FormRequest (`App\Http\Requests\GlobalSearchRequest`). The frontend is one vanilla-JS module (`public/js/global-search.js`) + one Blade partial (`resources/views/partials/_global_search.blade.php`).

## Role × Resource matrix (canonical)

| Resource | super_admin | admin | candidate | Scope |
|---|:-:|:-:|:-:|---|
| Pages | ✅ | ✅ | ✅ | each role gets only its own routes |
| Posts | ✅ | ✅ | ✅ active only | candidate filtered to `status='active'` |
| Candidates | ✅ | ✅ | own profile only | candidate scoped to `user_id=auth.id` |
| Applications | ✅ | ✅ | own only | scoped to `user_id=auth.id` |
| Exam Attempts | ✅ | ✅ | own only | scoped to `user_id=auth.id` |
| Questions | ✅ | ✅ | ❌ | admin-only resource |
| Users | ✅ | ❌ | ❌ | super-admin-only |

Authorization is enforced **at the query level**, not the view. Even a hand-crafted request can't leak — the orchestrator skips any `Searchable` whose `allowedRoles()` doesn't include the user's role, and each Searchable's `search()` method applies the role-specific scope.

## Adding a new Searchable

1. Create the class:

```php
namespace App\Services\GlobalSearch\Resources;

use App\Models\User;
use App\Services\GlobalSearch\Contracts\Searchable;
use App\Services\GlobalSearch\Result;
use Illuminate\Support\Collection;

class WidgetSearchable implements Searchable
{
    public function key(): string   { return 'widgets'; }
    public function label(): string { return 'Widgets'; }
    public function icon(): string  { return 'box'; }

    public function allowedRoles(): array
    {
        return ['super_admin', 'admin'];   // who can see ANY widget result
    }

    public function search(User $user, string $term, int $limit): Collection
    {
        $like = '%' . $term . '%';

        return \App\Models\Widget::query()
            ->select(['id', 'name', 'sku'])
            // ↓ apply per-role scope here. Never assume the orchestrator
            //   already filtered — defence in depth.
            ->where('name', 'LIKE', $like)
            ->orderByDesc('id')
            ->limit($limit)
            ->get()
            ->map(fn ($w) => new Result(
                id:       'widget:' . $w->id,
                title:    $w->name,
                subtitle: 'SKU ' . $w->sku,
                url:      route('admin.widgets.show', $w->id),
                group:    $this->key(),
                icon:     $this->icon(),
            ));
    }
}
```

2. Register it in `config/global_search.php`:

```php
'resources' => [
    PageSearchable::class,
    // … existing
    WidgetSearchable::class,    // ← add here; order is dropdown order
],
```

3. If the columns you `LIKE` against aren't indexed, add a migration to index them — `LIKE '%foo%'` over millions of rows without an index will hurt under load.

4. Add a feature test to `tests/Feature/GlobalSearch/GlobalSearchTest.php` covering:
   - the role × visibility expectations,
   - the negative case (a role that *shouldn't* see this group doesn't),
   - any per-row scoping (e.g. "candidate sees only own widgets").

That's it — no controller change, no route change, no JS change.

## Caching

- Per `(user_id, role, query-md5)` for 30 seconds (default; tune `cache.ttl`).
- No explicit invalidation — TTL is the invalidation. This is intentional: search results tolerate 30 s of staleness, and avoiding invalidation logic eliminates an entire class of bugs.
- Set `GLOBAL_SEARCH_CACHE=false` in `.env` to bypass caching during debugging.

## Performance notes

- Per-group cap: 6, total cap: 30, both configurable.
- All DB-backed Searchables eager-load the relations their result rows display — no N+1.
- Default driver is SQLite, so the service uses `LIKE '%term%'` against indexed columns. On MySQL/MariaDB you can later add `fullText()` indexes on the same columns and swap individual Searchables to `whereFullText()` without changing the contract.

## Security

- Endpoint is behind `auth` middleware and rate-limited.
- FormRequest enforces `q` between 2 and 100 characters and trims whitespace.
- All result rendering goes through `textContent` on the frontend; the highlight uses safe DOM tokenisation. No `innerHTML` on user-controlled strings.
- CNICs in the candidate group are masked in the dropdown (last 4 digits visible). Full value is on the detail page.

## Frontend

- Hooks the existing sidebar input — does **not** add a new one.
- 250 ms debounce, `AbortController` to drop stale fetches.
- ⌘K / Ctrl+K opens from anywhere. When the sidebar is collapsed it opens a Bootstrap modal fallback (`#globalSearchModal`).
- Keyboard: ↑ ↓ to move, Enter to navigate, Esc to close.
- ARIA: input is `combobox`, results container is `listbox`, items are `option` with `aria-selected`. A polite `aria-live` region announces result counts.

## Trade-offs in the current implementation

1. **Icons are single-glyph fallbacks** in the dropdown rather than inline SVG, to avoid shipping an icon library. Replace the `iconChar()` mapping in `public/js/global-search.js` with an inline-SVG sprite map if you want richer visuals.
2. **No "more results →" overflow page** — overflow is silently dropped at the per-group cap. If you want a "view all" link per group, surface a footer item in `GlobalSearchService::runFresh()` when `count >= perGroup` and link it to the relevant index page with `?q=<term>`.
3. **No fuzzy / typo tolerance** — substring matches only. For typo-tolerance, plug Laravel Scout + Meilisearch and adapt each `Searchable::search()` to call `Model::search($term)`.
