# Global Search Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a sitewide global search box that handles free-text queries (e.g. "specific performance"), structured section queries (e.g. "section 32 ipc"), citation lookups (e.g. "AIR 2024 SC 891"), and party-name searches — across all content types (Acts, Journals, Articles, Circulars, Amendements, Samasyas, Research Articles).

**Architecture:** A single `/search?q=...` route handled by `GlobalSearchController`. A `SearchIntent` service parses the query and dispatches to one of four engines: section JOIN, citation match, party LIKE, or FULLTEXT UNION across content tables. No new search index table — FULLTEXT indexes are added directly to existing `*_strip_tag` columns. Every search is logged to the existing `search_history` table.

**Tech Stack:** Laravel 12, MySQL 8 (FULLTEXT BOOLEAN MODE), Bootstrap 5.3 (existing front theme), PHPUnit (SQLite in-memory for unit tests).

**Known constraints:**
- Tests run on SQLite — FULLTEXT can't be exercised in automated tests. Parser logic gets unit tests; FULLTEXT behavior is verified manually against the dev MySQL DB.
- Existing tables `journals`, `amendements`, `samasyas`, `research_articles` already have FULLTEXT indexes (from the imported SQL dump and the research_articles migration). Only `acts`, `articles`, `circulars` need indexes added.
- Memory: `php artisan serve` on `127.0.0.1:8000`; use `{{ asset(...) }}`, not absolute paths.

---

## File Structure

**New files:**
- `database/migrations/2026_05_19_000000_add_fulltext_indexes_for_global_search.php` — FULLTEXT on acts, articles, circulars
- `database/migrations/2026_05_19_000001_add_global_to_search_history_content_type.php` — extend enum
- `app/Services/SearchIntent.php` — pure-logic query parser
- `app/Http/Controllers/GlobalSearchController.php` — handles `/search`
- `resources/views/layouts/front/partials/global-search.blade.php` — header search box
- `resources/views/users/global_search.blade.php` — results page
- `tests/Unit/SearchIntentTest.php` — parser unit tests
- `tests/Feature/GlobalSearchControllerTest.php` — route + intent dispatch tests (SQLite-compatible parts only)

**Modified files:**
- `routes/web.php` — register `/search` route
- `resources/views/layouts/front/partials/header.blade.php` — include the search box partial
- `resources/views/welcome.blade.php` — add hero search block

---

## Task 1: FULLTEXT migration for acts, articles, circulars

**Files:**
- Create: `database/migrations/2026_05_19_000000_add_fulltext_indexes_for_global_search.php`

- [ ] **Step 1: Create the migration file**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('acts', function (Blueprint $table) {
            $table->fullText(['act', 'description'], 'ft_acts_global');
        });

        Schema::table('articles', function (Blueprint $table) {
            $table->fullText(['title', 'article_strip_tag', 'author'], 'ft_articles_global');
        });

        Schema::table('circulars', function (Blueprint $table) {
            $table->fullText(['title', 'details_strip_tag', 'notification_no'], 'ft_circulars_global');
        });
    }

    public function down(): void
    {
        Schema::table('acts',      fn (Blueprint $t) => $t->dropFullText('ft_acts_global'));
        Schema::table('articles',  fn (Blueprint $t) => $t->dropFullText('ft_articles_global'));
        Schema::table('circulars', fn (Blueprint $t) => $t->dropFullText('ft_circulars_global'));
    }
};
```

- [ ] **Step 2: Run the migration**

Run: `php artisan migrate`
Expected: 3 ALTER TABLE statements succeed without error.

- [ ] **Step 3: Verify indexes exist**

Run:
```bash
mysql -uroot jotici_update -e "SHOW INDEX FROM acts      WHERE Index_type = 'FULLTEXT';"
mysql -uroot jotici_update -e "SHOW INDEX FROM articles  WHERE Index_type = 'FULLTEXT';"
mysql -uroot jotici_update -e "SHOW INDEX FROM circulars WHERE Index_type = 'FULLTEXT';"
```
Expected: each query returns rows naming `ft_acts_global` / `ft_articles_global` / `ft_circulars_global`.

- [ ] **Step 4: Commit**

```bash
git add database/migrations/2026_05_19_000000_add_fulltext_indexes_for_global_search.php
git commit -m "feat(search): add FULLTEXT indexes on acts, articles, circulars"
```

---

## Task 2: Extend search_history content_type ENUM

**Files:**
- Create: `database/migrations/2026_05_19_000001_add_global_to_search_history_content_type.php`

- [ ] **Step 1: Create the migration**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
    public function up(): void
    {
        DB::statement("ALTER TABLE search_history MODIFY COLUMN content_type ENUM('judgement', 'article', 'circular', 'amendment', 'samasya', 'research_article', 'global') NOT NULL");
    }

    public function down(): void
    {
        DB::statement("ALTER TABLE search_history MODIFY COLUMN content_type ENUM('judgement', 'article', 'circular', 'amendment', 'samasya', 'research_article') NOT NULL");
    }
};
```

- [ ] **Step 2: Run the migration**

Run: `php artisan migrate`
Expected: enum altered, no error.

- [ ] **Step 3: Verify the column accepts 'global'**

Run:
```bash
mysql -uroot jotici_update -e "SHOW COLUMNS FROM search_history LIKE 'content_type';"
```
Expected: type contains `'global'` in the enum list.

- [ ] **Step 4: Commit**

```bash
git add database/migrations/2026_05_19_000001_add_global_to_search_history_content_type.php
git commit -m "feat(search): allow 'global' value in search_history.content_type"
```

---

## Task 3: SearchIntent parser — failing tests first

**Files:**
- Create: `tests/Unit/SearchIntentTest.php`

- [ ] **Step 1: Write failing tests**

```php
<?php

namespace Tests\Unit;

use App\Services\SearchIntent;
use PHPUnit\Framework\TestCase;

class SearchIntentTest extends TestCase
{
    public function test_empty_query_returns_empty_kind(): void
    {
        $this->assertSame(['kind' => 'empty'], SearchIntent::parse(''));
        $this->assertSame(['kind' => 'empty'], SearchIntent::parse('   '));
    }

    public function test_parses_section_with_act_hint(): void
    {
        $r = SearchIntent::parse('section 32 ipc');
        $this->assertSame('section', $r['kind']);
        $this->assertSame('32', $r['number']);
        $this->assertSame('Indian Penal Code', $r['act_hint']);
    }

    public function test_parses_section_short_forms(): void
    {
        foreach (['sec 32 ipc', 's. 32 ipc', 'sec. 32 ipc', '§ 32 ipc'] as $q) {
            $r = SearchIntent::parse($q);
            $this->assertSame('section', $r['kind'], "Failed for: $q");
            $this->assertSame('32', $r['number']);
        }
    }

    public function test_parses_section_without_act(): void
    {
        $r = SearchIntent::parse('section 32');
        $this->assertSame('section', $r['kind']);
        $this->assertSame('32', $r['number']);
        $this->assertNull($r['act_hint']);
    }

    public function test_section_number_with_letter_suffix(): void
    {
        $r = SearchIntent::parse('section 498A ipc');
        $this->assertSame('section', $r['kind']);
        $this->assertSame('498A', $r['number']);
    }

    public function test_parses_citation_air_format(): void
    {
        $r = SearchIntent::parse('AIR 2024 SC 891');
        $this->assertSame('citation', $r['kind']);
        $this->assertSame('AIR 2024 SC 891', $r['citation']);
    }

    public function test_parses_citation_scc_format(): void
    {
        $r = SearchIntent::parse('(2024) 3 SCC 145');
        $this->assertSame('citation', $r['kind']);
    }

    public function test_parses_party_name_with_v(): void
    {
        $r = SearchIntent::parse('Kanshi Ram v. Om Prakash');
        $this->assertSame('party', $r['kind']);
        $this->assertSame('Kanshi Ram v. Om Prakash', $r['party']);
    }

    public function test_parses_party_name_with_vs(): void
    {
        $r = SearchIntent::parse('State vs Patel');
        $this->assertSame('party', $r['kind']);
    }

    public function test_falls_back_to_fulltext(): void
    {
        $r = SearchIntent::parse('specific performance of contracts');
        $this->assertSame('fulltext', $r['kind']);
        $this->assertSame('specific performance of contracts', $r['q']);
    }

    public function test_normalize_act_hint_known_abbreviations(): void
    {
        $this->assertSame('Indian Penal Code',          SearchIntent::normalizeActHint('ipc'));
        $this->assertSame('Code of Criminal Procedure', SearchIntent::normalizeActHint('crpc'));
        $this->assertSame('Civil Procedure',            SearchIntent::normalizeActHint('cpc'));
        $this->assertSame('Indian Evidence Act',        SearchIntent::normalizeActHint('evidence'));
        $this->assertSame('Indian Contract Act',        SearchIntent::normalizeActHint('contract'));
    }

    public function test_normalize_act_hint_unknown_returns_input(): void
    {
        $this->assertSame('Stamp Act', SearchIntent::normalizeActHint('Stamp Act'));
    }
}
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `vendor/bin/phpunit --filter SearchIntentTest`
Expected: all 12 tests fail with "Class App\Services\SearchIntent not found"

---

## Task 4: SearchIntent parser — implementation

**Files:**
- Create: `app/Services/SearchIntent.php`

- [ ] **Step 1: Implement the service**

```php
<?php

namespace App\Services;

class SearchIntent
{
    private const SECTION_PATTERN = '/\b(?:section|sec\.?|s\.|§)\s*([0-9]+[A-Z]?)\b\s*(?:of\s+)?(.*)$/i';
    private const CITATION_PATTERN = '/(AIR\s+\d{4}\s+[A-Z]+\s+\d+|\(\d{4}\)\s*\d+\s+SCC\s+\d+|\d{4}\s+SCC\s+OnLine\s+[A-Z]+\s+\d+)/i';
    private const PARTY_PATTERN = '/\b(?:v\.?|vs\.?|versus)\b/i';

    private const ACT_ABBREVIATIONS = [
        'ipc'      => 'Indian Penal Code',
        'crpc'     => 'Code of Criminal Procedure',
        'cpc'      => 'Civil Procedure',
        'evidence' => 'Indian Evidence Act',
        'contract' => 'Indian Contract Act',
        'ita'      => 'Income Tax Act',
        'gst'      => 'Goods and Services Tax',
        'nia'      => 'Negotiable Instruments Act',
    ];

    public static function parse(string $raw): array
    {
        $q = trim($raw);
        if ($q === '') {
            return ['kind' => 'empty'];
        }

        if (preg_match(self::CITATION_PATTERN, $q, $m)) {
            return ['kind' => 'citation', 'citation' => trim($m[0])];
        }

        if (preg_match(self::SECTION_PATTERN, $q, $m)) {
            $hint = trim($m[2] ?? '');
            return [
                'kind'     => 'section',
                'number'   => strtoupper($m[1]),
                'act_hint' => $hint === '' ? null : self::normalizeActHint($hint),
            ];
        }

        if (preg_match(self::PARTY_PATTERN, $q)) {
            return ['kind' => 'party', 'party' => $q];
        }

        return ['kind' => 'fulltext', 'q' => $q];
    }

    public static function normalizeActHint(string $hint): string
    {
        $key = strtolower(trim($hint));
        return self::ACT_ABBREVIATIONS[$key] ?? $hint;
    }
}
```

- [ ] **Step 2: Run tests, verify they pass**

Run: `vendor/bin/phpunit --filter SearchIntentTest`
Expected: 12 passing, 0 failures.

- [ ] **Step 3: Commit**

```bash
git add app/Services/SearchIntent.php tests/Unit/SearchIntentTest.php
git commit -m "feat(search): add SearchIntent parser for routing query types"
```

---

## Task 5: Route + controller skeleton + view stub

**Files:**
- Modify: `routes/web.php` (inside the `Route::group(['prefix' => 'user', 'as' => 'user.'], ...)` block, near the existing `/search-history` routes around line 277)
- Create: `app/Http/Controllers/GlobalSearchController.php`
- Create: `resources/views/users/global_search.blade.php`

- [ ] **Step 1: Add the route**

Find the line:
```php
Route::get('/search-history', [UserController::class, 'searchHistory'])->name('search.history');
```

Add **directly above** it (still inside the `user.` group):

```php
Route::get('/search', [\App\Http\Controllers\GlobalSearchController::class, 'index'])->name('search');
```

- [ ] **Step 2: Create the controller skeleton**

```php
<?php

namespace App\Http\Controllers;

use App\Services\SearchIntent;
use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;

class GlobalSearchController extends Controller
{
    public function index(Request $request): View
    {
        $raw    = (string) $request->input('q', '');
        $type   = $request->input('type');
        $intent = SearchIntent::parse($raw);

        $results = collect();
        $total   = 0;

        if ($intent['kind'] !== 'empty') {
            [$results, $total] = match ($intent['kind']) {
                'section'  => $this->searchBySection($intent, $type),
                'citation' => $this->searchByCitation($intent, $type),
                'party'    => $this->searchByParty($intent, $type),
                'fulltext' => $this->searchFullText($intent, $type),
            };

            $this->logSearch($raw, $intent, $total);
        }

        return view('users.global_search', [
            'q'       => $raw,
            'intent'  => $intent,
            'type'    => $type,
            'results' => $results,
            'total'   => $total,
        ]);
    }

    private function searchBySection(array $intent, ?string $type): array
    {
        return [collect(), 0];
    }

    private function searchByCitation(array $intent, ?string $type): array
    {
        return [collect(), 0];
    }

    private function searchByParty(array $intent, ?string $type): array
    {
        return [collect(), 0];
    }

    private function searchFullText(array $intent, ?string $type): array
    {
        return [collect(), 0];
    }

    private function logSearch(string $q, array $intent, int $total): void
    {
        // implemented in Task 10
    }
}
```

- [ ] **Step 3: Create the view stub**

```blade
@extends('layouts.front.app')

@section('title', 'Search — JotiJournal')

@section('content')
<div class="container py-4">
    <h1 class="h4 mb-3">
        @if($q === '')
            Search
        @else
            Results for "<em>{{ $q }}</em>"
        @endif
    </h1>

    @if($q !== '')
        <p class="text-muted mb-4">
            Intent: <code>{{ $intent['kind'] }}</code> · {{ $total }} result(s)
        </p>
    @endif

    @if($results->isEmpty() && $q !== '')
        <div class="alert alert-light border">No results found.</div>
    @endif

    {{-- Result rendering added in Task 12 --}}
</div>
@endsection
```

- [ ] **Step 4: Manually verify route works**

Run: `php artisan serve` (in a separate terminal if not already running)

Open in browser: `http://127.0.0.1:8000/user/search?q=hello`
Expected: page renders with heading "Results for hello", "Intent: fulltext · 0 result(s)", and "No results found." alert.

- [ ] **Step 5: Commit**

```bash
git add routes/web.php app/Http/Controllers/GlobalSearchController.php resources/views/users/global_search.blade.php
git commit -m "feat(search): scaffold /search route, controller, and results view"
```

---

## Task 6: Section search implementation

**Files:**
- Modify: `app/Http/Controllers/GlobalSearchController.php`

- [ ] **Step 1: Replace `searchBySection` with real implementation**

Replace the stub:

```php
private function searchBySection(array $intent, ?string $type): array
{
    // Section search only returns judgements (Journals).
    if ($type !== null && $type !== 'Journal') {
        return [collect(), 0];
    }

    $query = \App\Models\Journal::query()
        ->select('journals.*')
        ->join('journal_sections as js', 'js.journal_id', '=', 'journals.id')
        ->join('sections as s',          's.id',          '=', 'js.section_id')
        ->join('acts as a',              'a.id',          '=', 's.act_id')
        ->where('s.section', $intent['number'])
        ->when($intent['act_hint'], fn ($q, $hint) =>
            $q->where('a.act', 'like', '%' . $hint . '%')
        )
        ->with(['sections.act'])
        ->orderByDesc('journals.publication_year')
        ->distinct();

    $total   = (clone $query)->count('journals.id');
    $results = $query->limit(20)->get()->map(fn ($j) => [
        'type'        => 'Judgement',
        'title'       => $j->party_name,
        'subtitle'    => $j->citation,
        'body'        => $j->head_note_strip_tag,
        'year'        => $j->publication_year,
        'url'         => route('user.view_judgement', $j->id),
        'sections'    => $j->sections->map(fn ($s) =>
            ($s->act->act ?? '?') . ' §' . $s->section
        )->all(),
    ]);

    return [$results, $total];
}
```

- [ ] **Step 2: Manually verify against dev DB**

Open: `http://127.0.0.1:8000/user/search?q=section+32+ipc`
Expected: page shows "Intent: section · N result(s)" where N matches what `SELECT COUNT(DISTINCT j.id) FROM journals j JOIN journal_sections js ON js.journal_id=j.id JOIN sections s ON s.id=js.section_id JOIN acts a ON a.id=s.act_id WHERE s.section='32' AND a.act LIKE '%Indian Penal Code%'` returns.

If the route name `user.view_judgement` doesn't exist, find the correct route name with `php artisan route:list | grep judgement` and update the controller.

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/GlobalSearchController.php
git commit -m "feat(search): implement section-based judgement search"
```

---

## Task 7: Full-text UNION search across content types

**Files:**
- Modify: `app/Http/Controllers/GlobalSearchController.php`

- [ ] **Step 1: Add a private helper for the BOOLEAN MODE query string**

Add this method to the controller (above the search methods):

```php
private function toBooleanQuery(string $q): string
{
    return collect(preg_split('/\s+/', $q))
        ->filter(fn ($w) => $w !== '')
        ->map(fn ($w) => '+' . preg_replace('/[^\p{L}\p{N}]/u', '', $w) . '*')
        ->implode(' ');
}
```

- [ ] **Step 2: Replace `searchFullText` with the UNION query**

```php
private function searchFullText(array $intent, ?string $type): array
{
    $q    = $intent['q'];
    $bool = $this->toBooleanQuery($q);

    if ($bool === '') {
        return [collect(), 0];
    }

    $parts = [
        'Journal'         => [
            'table'   => 'journals',
            'id'      => 'id',
            'title'   => 'party_name',
            'body'    => 'head_note_strip_tag',
            'extra'   => "CONCAT(citation, ' ', COALESCE(held_strip_tag, ''))",
            'date'    => 'publication_year',
            'route'   => 'user.view_judgement',
        ],
        'Article'         => [
            'table' => 'articles', 'id' => 'id',
            'title' => 'title',    'body' => 'article_strip_tag',
            'extra' => 'author',   'date' => 'publish_date',
            'route' => 'user.view_article',
        ],
        'Circular'        => [
            'table' => 'circulars', 'id' => 'id',
            'title' => 'title',     'body' => 'details_strip_tag',
            'extra' => 'notification_no', 'date' => 'publish_date',
            'route' => 'user.view_circular',
        ],
        'Act'             => [
            'table' => 'acts', 'id' => 'id',
            'title' => 'act',  'body' => 'description',
            'extra' => "''",   'date' => 'created_at',
            'route' => 'user.amendments',
        ],
        'Amendement'      => [
            'table' => 'amendements', 'id' => 'id',
            'title' => 'title',       'body' => 'detail_strip_tag',
            'extra' => "''",          'date' => 'publish_date',
            'route' => 'user.view_amendment',
        ],
        'Samasya'         => [
            'table' => 'samasyas', 'id' => 'id',
            'title' => 'samasya',  'body' => 'samadhan_strip_tag',
            'extra' => "''",       'date' => 'publish_date',
            'route' => 'user.samasyas',
        ],
        'ResearchArticle' => [
            'table' => 'research_articles', 'id' => 'id',
            'title' => 'title',             'body' => 'article_strip_tag',
            'extra' => 'author',            'date' => 'publish_date',
            'route' => 'user.research.articles',
        ],
    ];

    if ($type !== null && isset($parts[$type])) {
        $parts = [$type => $parts[$type]];
    }

    $unions = [];
    $bindings = [];
    foreach ($parts as $typeKey => $p) {
        $unions[] = "(SELECT '{$typeKey}' AS type, {$p['id']} AS id,
                            {$p['title']} AS title,
                            LEFT({$p['body']}, 300) AS snippet,
                            {$p['date']} AS sort_date,
                            (MATCH({$p['title']}) AGAINST(? IN BOOLEAN MODE) * 3) +
                             MATCH({$p['body']})  AGAINST(? IN BOOLEAN MODE) AS score
                       FROM {$p['table']}
                      WHERE MATCH({$p['title']}) AGAINST(? IN BOOLEAN MODE)
                         OR MATCH({$p['body']})  AGAINST(? IN BOOLEAN MODE))";
        array_push($bindings, $bool, $bool, $bool, $bool);
    }

    $sql = implode(' UNION ALL ', $unions) . ' ORDER BY score DESC LIMIT 50';

    $rows = \DB::select($sql, $bindings);

    $results = collect($rows)->map(function ($row) use ($parts) {
        $p = $parts[$row->type];
        return [
            'type'     => $row->type,
            'title'    => $row->title,
            'subtitle' => null,
            'body'     => $row->snippet,
            'year'     => $row->sort_date,
            'url'      => $this->buildUrl($p['route'], $row->id),
            'score'    => $row->score,
        ];
    });

    return [$results, $results->count()];
}

private function buildUrl(string $routeName, int $id): string
{
    try {
        return route($routeName, $id);
    } catch (\Throwable) {
        return '#';
    }
}
```

- [ ] **Step 3: Manually verify**

Open: `http://127.0.0.1:8000/user/search?q=specific+performance`
Expected: results appear, intent is "fulltext", and the snippet column shows the first 300 chars of body text containing the search terms.

Try the type filter: `http://127.0.0.1:8000/user/search?q=specific+performance&type=Article`
Expected: only Article-type rows.

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/GlobalSearchController.php
git commit -m "feat(search): UNION FULLTEXT search across 7 content tables"
```

---

## Task 8: Citation search

**Files:**
- Modify: `app/Http/Controllers/GlobalSearchController.php`

- [ ] **Step 1: Replace `searchByCitation` with real implementation**

```php
private function searchByCitation(array $intent, ?string $type): array
{
    if ($type !== null && $type !== 'Journal') {
        return [collect(), 0];
    }

    $needle = $intent['citation'];

    $query = \App\Models\Journal::query()
        ->where('citation', 'like', '%' . $needle . '%')
        ->orWhere('citation_strip_tag', 'like', '%' . $needle . '%')
        ->orderByDesc('publication_year');

    $total   = (clone $query)->count();
    $results = $query->limit(20)->get()->map(fn ($j) => [
        'type'     => 'Judgement',
        'title'    => $j->party_name,
        'subtitle' => $j->citation,
        'body'     => $j->head_note_strip_tag,
        'year'     => $j->publication_year,
        'url'      => $this->buildUrl('user.view_judgement', $j->id),
    ]);

    return [$results, $total];
}
```

- [ ] **Step 2: Manually verify**

Open: `http://127.0.0.1:8000/user/search?q=AIR+2024+SC+891` (substitute a real citation from the DB)
Expected: matching judgement(s) appear; "Intent: citation".

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/GlobalSearchController.php
git commit -m "feat(search): citation lookup for journals"
```

---

## Task 9: Party-name search

**Files:**
- Modify: `app/Http/Controllers/GlobalSearchController.php`

- [ ] **Step 1: Replace `searchByParty` with real implementation**

```php
private function searchByParty(array $intent, ?string $type): array
{
    if ($type !== null && $type !== 'Journal') {
        return [collect(), 0];
    }

    $needle = $intent['party'];

    $query = \App\Models\Journal::query()
        ->where('party_name', 'like', '%' . $needle . '%')
        ->orderByDesc('publication_year');

    $total   = (clone $query)->count();
    $results = $query->limit(20)->get()->map(fn ($j) => [
        'type'     => 'Judgement',
        'title'    => $j->party_name,
        'subtitle' => $j->citation,
        'body'     => $j->head_note_strip_tag,
        'year'     => $j->publication_year,
        'url'      => $this->buildUrl('user.view_judgement', $j->id),
    ]);

    return [$results, $total];
}
```

- [ ] **Step 2: Manually verify**

Open: `http://127.0.0.1:8000/user/search?q=State+v+Patel` (substitute a real party from the DB)
Expected: matching judgement(s); "Intent: party".

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/GlobalSearchController.php
git commit -m "feat(search): party-name lookup for judgements"
```

---

## Task 10: Wire SearchHistory logging

**Files:**
- Modify: `app/Http/Controllers/GlobalSearchController.php`

- [ ] **Step 1: Replace `logSearch` with real implementation**

```php
private function logSearch(string $q, array $intent, int $total): void
{
    if (!auth()->check()) {
        return;
    }

    \App\Models\SearchHistory::create([
        'user_id'        => auth()->id(),
        'content_type'   => 'global',
        'search_type'    => $intent['kind'],
        'search_summary' => mb_substr($q, 0, 500),
        'params'         => $intent,
        'results_count'  => $total,
    ]);
}
```

- [ ] **Step 2: Verify `SearchHistory` model has the right fillable**

Run: `grep -A 20 "class SearchHistory" app/Models/SearchHistory.php`

If `content_type`, `search_type`, `search_summary`, `params`, `results_count` are NOT in `$fillable`, add them. The `params` field should also have `'params' => 'array'` in `$casts` so JSON encode/decode works.

If updates are needed, modify the model and stage that change too.

- [ ] **Step 3: Manually verify logging**

Log in as any user (memory: `mpsjajournal@gmail.com` superadmin works).
Open: `http://127.0.0.1:8000/user/search?q=specific+performance`

Run:
```bash
mysql -uroot jotici_update -e "SELECT id, user_id, content_type, search_type, search_summary, results_count FROM search_history ORDER BY id DESC LIMIT 3;"
```
Expected: the most recent row has `content_type = 'global'`, `search_type = 'fulltext'`, summary matches the query.

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/GlobalSearchController.php app/Models/SearchHistory.php
git commit -m "feat(search): log every global search to search_history"
```

---

## Task 11: Header search box partial

**Files:**
- Create: `resources/views/layouts/front/partials/global-search.blade.php`

- [ ] **Step 1: Create the partial**

```blade
<div class="global-search-bar bg-light border-bottom py-2">
    <div class="container">
        <form action="{{ route('user.search') }}" method="GET" class="d-flex gap-2 align-items-center" role="search">
            <div class="input-group input-group-sm flex-grow-1">
                <span class="input-group-text bg-white border-end-0">
                    <i class="fa-solid fa-magnifying-glass text-muted"></i>
                </span>
                <input
                    type="search"
                    name="q"
                    value="{{ request('q') }}"
                    class="form-control border-start-0"
                    placeholder='Try "section 32 ipc", "AIR 2024 SC 891", or "specific performance"'
                    autocomplete="off"
                    aria-label="Global search">
            </div>

            <select name="type" class="form-select form-select-sm" style="max-width: 160px;" aria-label="Filter by type">
                <option value="">All content</option>
                <option value="Journal"         @selected(request('type')==='Journal')>Judgements</option>
                <option value="Article"         @selected(request('type')==='Article')>Articles</option>
                <option value="Circular"        @selected(request('type')==='Circular')>Circulars</option>
                <option value="Amendement"      @selected(request('type')==='Amendement')>Amendments</option>
                <option value="Act"             @selected(request('type')==='Act')>Acts</option>
                <option value="Samasya"         @selected(request('type')==='Samasya')>Samasya</option>
                <option value="ResearchArticle" @selected(request('type')==='ResearchArticle')>Research</option>
            </select>

            <button type="submit" class="btn btn-sm btn-primary">Search</button>
        </form>
    </div>
</div>
```

---

## Task 12: Include the search bar in the header

**Files:**
- Modify: `resources/views/layouts/front/partials/header.blade.php`

- [ ] **Step 1: Include the partial just before the `<nav class="main-nav-bar">` line**

Find line 74:
```blade
<nav class="main-nav-bar navbar navbar-expand-lg" data-bs-theme="dark">
```

Insert directly above it:

```blade
@include('layouts.front.partials.global-search')
```

- [ ] **Step 2: Manually verify**

Open any page (e.g. `http://127.0.0.1:8000/`).
Expected: a light-grey search bar appears between the top header and the dark navbar, with a search icon, input, type dropdown, and Search button.

Submit a query (e.g. "section 32 ipc"). Expected: navigates to `/user/search?q=section+32+ipc&type=` and renders the results page.

- [ ] **Step 3: Commit**

```bash
git add resources/views/layouts/front/partials/global-search.blade.php resources/views/layouts/front/partials/header.blade.php
git commit -m "feat(search): add sitewide search bar to header"
```

---

## Task 13: Results page rendering

**Files:**
- Modify: `resources/views/users/global_search.blade.php`

- [ ] **Step 1: Replace the stub with a full results view**

```blade
@extends('layouts.front.app')

@section('title', $q ? "Search: {$q} — JotiJournal" : 'Search — JotiJournal')

@section('content')
<div class="container py-4">

    @if($q === '')
        <div class="text-center py-5">
            <h1 class="h3 mb-3">Search the JotiJournal corpus</h1>
            <p class="text-muted mb-4">
                Use the search bar above. Examples:
                <code>section 32 ipc</code>,
                <code>AIR 2024 SC 891</code>,
                <code>State vs Patel</code>,
                <code>specific performance</code>.
            </p>
        </div>
    @else
        <div class="d-flex justify-content-between align-items-baseline mb-3">
            <h1 class="h4 mb-0">
                Results for <em>"{{ $q }}"</em>
            </h1>
            <span class="text-muted small">
                {{ $total }} result(s) ·
                Matched as <span class="badge bg-secondary">{{ $intent['kind'] }}</span>
            </span>
        </div>

        @if($intent['kind'] === 'section')
            <p class="text-muted small mb-3">
                Section <strong>{{ $intent['number'] }}</strong>
                @if($intent['act_hint'])
                    of <strong>{{ $intent['act_hint'] }}</strong>
                @endif
            </p>
        @endif

        @if($results->isEmpty())
            <div class="alert alert-light border">No results found.</div>
        @else
            <div class="list-group">
                @foreach($results as $r)
                    <a href="{{ $r['url'] }}" class="list-group-item list-group-item-action">
                        <div class="d-flex justify-content-between align-items-start mb-1">
                            <strong>{{ $r['title'] }}</strong>
                            <span class="badge bg-info text-dark">{{ $r['type'] }}</span>
                        </div>
                        @if(!empty($r['subtitle']))
                            <div class="small text-muted mb-1">{{ $r['subtitle'] }}</div>
                        @endif
                        @if(!empty($r['body']))
                            <div class="small text-secondary">
                                {{ \Illuminate\Support\Str::limit(strip_tags($r['body']), 220) }}
                            </div>
                        @endif
                        @if(!empty($r['sections']))
                            <div class="small text-muted mt-1">
                                Cited: {{ implode(' · ', $r['sections']) }}
                            </div>
                        @endif
                        @if(!empty($r['year']))
                            <div class="small text-muted mt-1">{{ $r['year'] }}</div>
                        @endif
                    </a>
                @endforeach
            </div>
        @endif
    @endif

</div>
@endsection
```

- [ ] **Step 2: Manually verify all 4 intents render correctly**

Test each:
- `?q=section+32+ipc` — section intent, judgements with cited sections
- `?q=AIR+2024+SC+891` — citation intent (use a real citation from your DB)
- `?q=State+vs+Patel` — party intent (use a real party from your DB)
- `?q=specific+performance` — fulltext intent, mixed result types

For each: verify the badge color/type label is correct and the link goes to the right detail page.

- [ ] **Step 3: Commit**

```bash
git add resources/views/users/global_search.blade.php
git commit -m "feat(search): render results page with intent-aware metadata"
```

---

## Task 14: Hero search on welcome page

**Files:**
- Modify: `resources/views/welcome.blade.php`

- [ ] **Step 1: Find a suitable hero section in welcome.blade.php**

Run: `grep -n "hero\|banner\|jumbotron\|<section" resources/views/welcome.blade.php | head`

Pick the first hero-style section. If none exists, add the search block near the top of the `@section('content')` body.

- [ ] **Step 2: Insert the hero search block**

```blade
<section class="hero-search py-5 bg-light text-center">
    <div class="container">
        <h2 class="h3 mb-2">Search Indian legal corpus</h2>
        <p class="text-muted mb-4">
            Acts, amendments, judgements, articles, circulars and more — one search.
        </p>
        <form action="{{ route('user.search') }}" method="GET"
              class="mx-auto" style="max-width: 640px;">
            <div class="input-group input-group-lg">
                <input type="search" name="q" class="form-control"
                       placeholder='Try "section 32 ipc" or "specific performance"'
                       autocomplete="off">
                <button type="submit" class="btn btn-primary">Search</button>
            </div>
            <div class="small text-muted mt-2">
                Examples:
                <a href="{{ route('user.search', ['q' => 'section 302 ipc']) }}">section 302 ipc</a> ·
                <a href="{{ route('user.search', ['q' => 'specific performance']) }}">specific performance</a> ·
                <a href="{{ route('user.search', ['q' => 'habeas corpus']) }}">habeas corpus</a>
            </div>
        </form>
    </div>
</section>
```

- [ ] **Step 3: Manually verify**

Open: `http://127.0.0.1:8000/`
Expected: hero search block appears on homepage. Clicking the example links triggers a real search.

- [ ] **Step 4: Commit**

```bash
git add resources/views/welcome.blade.php
git commit -m "feat(search): add hero search block to homepage"
```

---

## Task 15: Final manual verification pass

- [ ] **Step 1: Run the full unit test suite**

Run: `vendor/bin/phpunit`
Expected: all tests pass, including the 12 `SearchIntentTest` cases.

- [ ] **Step 2: Walk through every intent on the live site**

For each query below: open the URL, confirm "Matched as" badge is correct, confirm at least one result links to a real page.

| Query                              | Expected intent | Expected result type      |
| ---------------------------------- | --------------- | ------------------------- |
| `section 32 ipc`                   | section         | Judgements only           |
| `section 302`                      | section         | Judgements (no act filter)|
| `s. 498A ipc`                      | section         | Judgements                |
| `AIR 1966 SC 119` *(use a real one)* | citation      | Judgement                 |
| `Kanshi Ram v. Om Prakash` *(real)* | party          | Judgement(s)              |
| `specific performance`             | fulltext        | Mixed types               |
| `habeas corpus`                    | fulltext        | Mixed types               |
| *(empty)*                          | empty           | Empty-state copy          |

- [ ] **Step 3: Verify type filter**

Open: `http://127.0.0.1:8000/user/search?q=contract&type=Article`
Expected: only Article-type results.

- [ ] **Step 4: Verify SearchHistory logging**

While logged in as `mpsjajournal@gmail.com`, run several searches, then check:

Run: `mysql -uroot jotici_update -e "SELECT search_type, search_summary, results_count, created_at FROM search_history WHERE content_type='global' ORDER BY id DESC LIMIT 10;"`

Expected: every search you just ran appears.

- [ ] **Step 5: Final commit (no-op if nothing changed)**

```bash
git status
# If any uncommitted: git add -p && git commit -m "chore(search): final touches"
```

---

## Self-review notes

- Spec coverage:
  - Free-text search → Task 7
  - Section search → Task 6
  - Citation search → Task 8
  - Party search → Task 9
  - Header placement → Tasks 11–12
  - Hero search → Task 14
  - Results page → Task 13
  - History logging → Task 10
- Detail-page route names (`user.view_judgement`, `user.view_article`, `user.view_circular`, `user.view_amendment`) are used in Tasks 6–9 — verify with `php artisan route:list | grep user.view` before starting Task 6. If different, update those calls in the controller.
- The `samasyas` and `acts` routes I picked (`user.samasyas`, `user.amendments`) go to listing pages, not detail pages, because no per-id detail route exists for these in `routes/web.php`. If detail routes get added later, swap them in.
- FULLTEXT BOOLEAN MODE strips out single chars and stopwords. For very short queries ("contract") relevance is fine; for stopword-heavy queries ("the law of") most words are skipped. This is acceptable for v1; revisit if user feedback shows it's a problem.
- No backwards-compat needed: this is purely additive.

---

## Appendix A: Raw SQL reference

Every query the controller and migrations produce, in plain SQL. Copy-paste into phpMyAdmin / `mysql` CLI / MySQL Workbench against `jotici_update` for ad-hoc verification.

Connect with the project's `.env`:

```bash
mysql -h 127.0.0.1 -P 3306 -uroot jotici_update
```

### A1 — Migration: add FULLTEXT to acts, articles, circulars *(Task 1)*

```sql
ALTER TABLE `acts`
  ADD FULLTEXT INDEX `ft_acts_global` (`act`, `description`);

ALTER TABLE `articles`
  ADD FULLTEXT INDEX `ft_articles_global` (`title`, `article_strip_tag`, `author`);

ALTER TABLE `circulars`
  ADD FULLTEXT INDEX `ft_circulars_global` (`title`, `details_strip_tag`, `notification_no`);
```

Verify:

```sql
SHOW INDEX FROM acts      WHERE Index_type = 'FULLTEXT';
SHOW INDEX FROM articles  WHERE Index_type = 'FULLTEXT';
SHOW INDEX FROM circulars WHERE Index_type = 'FULLTEXT';
```

Rollback:

```sql
ALTER TABLE `acts`      DROP INDEX `ft_acts_global`;
ALTER TABLE `articles`  DROP INDEX `ft_articles_global`;
ALTER TABLE `circulars` DROP INDEX `ft_circulars_global`;
```

### A2 — Migration: extend search_history enum *(Task 2)*

```sql
ALTER TABLE `search_history`
  MODIFY COLUMN `content_type`
  ENUM('judgement','article','circular','amendment','samasya','research_article','global') NOT NULL;
```

Verify:

```sql
SHOW COLUMNS FROM search_history LIKE 'content_type';
```

### A3 — Section search *(Task 6)*

For `q = "section 32 ipc"` → intent: `{number: '32', act_hint: 'Indian Penal Code'}`:

```sql
SELECT DISTINCT j.*
FROM   journals j
JOIN   journal_sections js ON js.journal_id = j.id
JOIN   sections s          ON s.id = js.section_id
JOIN   acts a              ON a.id = s.act_id
WHERE  s.section = '32'
  AND  a.act LIKE '%Indian Penal Code%'
ORDER  BY j.publication_year DESC
LIMIT  20;
```

Count (used for `$total`):

```sql
SELECT COUNT(DISTINCT j.id) AS total
FROM   journals j
JOIN   journal_sections js ON js.journal_id = j.id
JOIN   sections s          ON s.id = js.section_id
JOIN   acts a              ON a.id = s.act_id
WHERE  s.section = '32'
  AND  a.act LIKE '%Indian Penal Code%';
```

For `q = "section 32"` (no act hint) — drop the `a.act LIKE` clause:

```sql
SELECT DISTINCT j.*
FROM   journals j
JOIN   journal_sections js ON js.journal_id = j.id
JOIN   sections s          ON s.id = js.section_id
JOIN   acts a              ON a.id = s.act_id
WHERE  s.section = '32'
ORDER  BY j.publication_year DESC
LIMIT  20;
```

Disambiguation summary (which Acts have a §32?):

```sql
SELECT a.id, a.act, COUNT(DISTINCT j.id) AS total
FROM   journals j
JOIN   journal_sections js ON js.journal_id = j.id
JOIN   sections s          ON s.id = js.section_id
JOIN   acts a              ON a.id = s.act_id
WHERE  s.section = '32'
GROUP  BY a.id, a.act
ORDER  BY total DESC;
```

### A4 — Free-text UNION search *(Task 7)*

For `q = "specific performance"` → BOOLEAN string `"+specific* +performance*"`:

```sql
SELECT * FROM (
  (SELECT 'Journal' AS type, id, party_name AS title,
          LEFT(head_note_strip_tag, 300) AS snippet,
          publication_year AS sort_date,
          (MATCH(party_name) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(head_note_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE) AS score
     FROM journals
    WHERE MATCH(party_name) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(head_note_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
  UNION ALL
  (SELECT 'Article', id, title,
          LEFT(article_strip_tag, 300),
          publish_date,
          (MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(article_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
     FROM articles
    WHERE MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(article_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
  UNION ALL
  (SELECT 'Circular', id, title,
          LEFT(details_strip_tag, 300),
          publish_date,
          (MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(details_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
     FROM circulars
    WHERE MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(details_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
  UNION ALL
  (SELECT 'Act', id, act,
          LEFT(description, 300),
          created_at,
          (MATCH(act) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(description) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
     FROM acts
    WHERE MATCH(act) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(description) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
  UNION ALL
  (SELECT 'Amendement', id, title,
          LEFT(detail_strip_tag, 300),
          publish_date,
          (MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(detail_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
     FROM amendements
    WHERE MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(detail_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
  UNION ALL
  (SELECT 'Samasya', id, samasya,
          LEFT(samadhan_strip_tag, 300),
          publish_date,
          (MATCH(samasya) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(samadhan_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
     FROM samasyas
    WHERE MATCH(samasya) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(samadhan_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
  UNION ALL
  (SELECT 'ResearchArticle', id, title,
          LEFT(article_strip_tag, 300),
          publish_date,
          (MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE) * 3) +
           MATCH(article_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
     FROM research_articles
    WHERE MATCH(title) AGAINST('+specific* +performance*' IN BOOLEAN MODE)
       OR MATCH(article_strip_tag) AGAINST('+specific* +performance*' IN BOOLEAN MODE))
) AS combined
ORDER BY score DESC
LIMIT 50;
```

When `type=Article` filter is applied, drop all UNION branches except the Article one.

### A5 — Citation lookup *(Task 8)*

For `q = "AIR 2024 SC 891"`:

```sql
SELECT *
FROM   journals
WHERE  citation             LIKE '%AIR 2024 SC 891%'
   OR  citation_strip_tag   LIKE '%AIR 2024 SC 891%'
ORDER  BY publication_year DESC
LIMIT  20;
```

### A6 — Party-name lookup *(Task 9)*

For `q = "State vs Patel"`:

```sql
SELECT *
FROM   journals
WHERE  party_name LIKE '%State vs Patel%'
ORDER  BY publication_year DESC
LIMIT  20;
```

### A7 — SearchHistory insert *(Task 10)*

```sql
INSERT INTO search_history
  (user_id, content_type, search_type, search_summary, params, results_count, created_at)
VALUES
  (1, 'global', 'fulltext', 'specific performance',
   JSON_OBJECT('kind','fulltext','q','specific performance'),
   24, NOW());
```

Inspect recent global searches:

```sql
SELECT id, user_id, search_type, search_summary, results_count, created_at
FROM   search_history
WHERE  content_type = 'global'
ORDER  BY id DESC
LIMIT  10;
```

### A8 — BOOLEAN MODE quick reference

| Operator     | Meaning                                            | Example                       |
| ------------ | -------------------------------------------------- | ----------------------------- |
| `+word`      | word MUST be present                               | `+contract`                   |
| `-word`      | word MUST NOT be present                           | `+contract -void`             |
| `word*`      | prefix match                                       | `negl*` matches `negligence`  |
| `"phrase"`   | exact phrase                                       | `"specific performance"`      |
| `>word`      | increase row's rank if matched                     | `+law >negligence`            |
| `<word`      | decrease row's rank if matched                     | `+law <draft`                 |
| `(a b) c`    | group                                              | `+(law contract) -draft`      |

The controller's `toBooleanQuery()` produces the simple `+word1* +word2* …` form. You can hand-craft fancier strings if you want to test relevance in phpMyAdmin.

### A9 — MySQL FULLTEXT config notes

If "Section 32" or other 2-char tokens don't match, check:

```sql
SHOW VARIABLES LIKE 'ft_min_word_len';        -- default 4 → raise to 2
SHOW VARIABLES LIKE 'innodb_ft_min_token_size';  -- default 3 → raise to 2 for InnoDB tables
```

After lowering these in `my.cnf` (or `my.ini` for XAMPP), the indexes must be rebuilt:

```sql
REPAIR TABLE journals    QUICK;     -- MyISAM
ALTER TABLE journals    ENGINE = InnoDB;  -- InnoDB: drop & re-add FULLTEXT
ALTER TABLE journals    DROP INDEX head_note_3, ADD FULLTEXT KEY head_note_3 (head_note_strip_tag, citation_strip_tag, held_strip_tag);
```

Most legal-search queries are 4+ chars (`section`, `negligence`, `contract`) so this is rarely needed. The structured "section 32 ipc" path uses JOINs, not FULLTEXT, and is unaffected.
