---
id: SVC-SEARCH
tipo: servico
onda: 2
status: concluido
unidades: [SVC-SEARCH, SVC-INDEX]
fontes:
  - V2/Services/Search/
  - V2/Services/README.md
depende_de: [CORE-BOOT, CORE-DATA, CORE-AUTH, CORE-RUNTIME]
consumidores: [MOD-PUBLICACOES-NOTICIAS, MOD-PUBLICACOES-AGENDA, MOD-CONEXOES-ASSOCIADOS, MOD-CONEXOES-UNIDADEREGIONAL, MOD-CONEXOES-PARCEIROSINSTITUCIONAIS, MOD-INSTITUCIONAL-SOBRENOS, MOD-PUBLICACOES-TRABALHECONOSCO, MOD-LAYOUTS-BASICPAGE, SVC-CONTENTRICHEDITOR, SVC-SEO, SVC-DASHBOARD]
criticidade: alta
dono: SvcSearchDash
---

# Serviço Search — índice global (portal + CMS)

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Mantém duas tabelas de índice (`v2_public_search_index`, `v2_cms_links_index`) e responde a `GET /busca` (portal, máx. 20 itens) e `GET /cms/search` (CMS, máx. 10 itens), transformando termos em links para conteúdo publicado e para telas de edição. |
| Entrada | Modelos Eloquent nos hooks `saved`/`deleted` → `PublicIndexerService::index/remove` e `CMSIndexerService::index/remove`; `GET /busca?q=`, `GET /cms/search?q=` (HTML ou JSON), comando `php artisan v2:reindex-public [--only=...]`. |
| Saída | Linhas em `v2_public_search_index` (com `search_vector` recalculado por trigger) e em `v2_cms_links_index`; HTML `v2-portal::search.index`, HTML `v2-cms::search.index`, JSON `{ok,data:{term,results},message,error}`. |
| Efeitos colaterais | `Log::error` em falha de indexação (engolida); recálculo de `tsvector` por linha; escrita em duas tabelas por `save()` de qualquer entidade coberta; nenhuma auditoria (`ActivityLogger` não é chamado). |
| Particularidade central | O índice público **é uma projeção denormalizada** de 8 modelos, sem FK e sem fila: cada model escreve por conta própria, e a URL pública não vem do índice, mas de um `switch ($module)` fixo em `SearchService::publicResultUrl` (`V2/Services/Search/Services/SearchService.php:68-89`). Não há busca por regional, nem tags no vetor, nem paginação. |
| Como se integra | `V2\Services\Search\Services\{SearchService, PublicIndexerService, CMSIndexerService}`, `V2\Services\Search\DTOs\{PublicSearchDTO, CMSIndexDTO}`; injeção por `app()` ou construtor; comando registrado em `app/Console/Kernel.php:17`. |
| Se removido, quebra | `/busca` e `/cms/search` (500 — `SearchController` injeta `SearchService`), o seletor de links/iframes do `ContentRichEditor` (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:70-75`), o enriquecimento do ranking SEO (`V2/Services/SEO/Services/SEOService.php:269-272`), o card “Páginas Indexadas” do dashboard (`V2/Services/Dashboard/DashboardStatsService.php:185-198`) e todos os hooks `syncToSearch()` dos 8 modelos. |
| Superfície pública | `SearchService::publicSearch`, `SearchService::cmsSearch`, `SearchService::publicResultUrl` (estático), `PublicIndexerService::index/remove`, `CMSIndexerService::index/remove`, `PublicSearchDTO`, `CMSIndexDTO` (`::fromArray`), comando `v2:reindex-public`. |

## 1. Efeito

### 1.1 Transformação que o serviço realiza

| Etapa | Entrada concreta | Saída concreta |
|---|---|---|
| Indexação pública | `PublicSearchDTO(title, slug, module, entity_type, entity_id, summary, tags, search_deep_content)` | `INSERT ... ON CONFLICT (entity_type, entity_id) DO UPDATE` em `v2_public_search_index`, com `search_vector` recalculado `BEFORE INSERT OR UPDATE` (`V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:63-79`) |
| Remoção pública | `(entity_type, entity_id)` | `DELETE` das linhas correspondentes (`V2/Services/Search/Services/PublicIndexerService.php:66-72`) |
| Indexação CMS | `CMSIndexDTO(module, edit_url, label, searchable_text, metadata)` | `INSERT ... ON CONFLICT (module, edit_url) DO UPDATE` em `v2_cms_links_index` (`V2/Services/Search/Services/CMSIndexerService.php:27-45`) |
| Busca portal | `q` não vazio | `Collection` de até 20 objetos `{title, slug, module, entity_id, headline, rank, result_url}` (`SearchService.php:26-56`) |
| Busca CMS | `q` não vazio + usuário Sentinel | `Collection` de até 10 objetos `{label, module, edit_url, highlight}` (menus + registros) (`SearchService.php:97-235`) |

**O que fica pesquisável no portal** (8 entidades, ver `indexacao.md`): Notícias, Eventos, Unidades Regionais, Associados, Parceiros Institucionais, Diretoria (`board_member`), Trabalhe Conosco e BasicPages. **Onde aparece**: apenas a página `/busca`; não há autocomplete público, nem busca por API, nem widget embutido no portal (o overlay do header é só um `<form action="{{ url('busca') }}">`, `V2/layouts/portal/header/header-search-area.blade.php:22-37`).

### 1.2 Efeitos sobre dados e sistema

| Efeito | Recurso afetado | Ponto no código | Fonte |
|---|---|---|---|
| Upsert do índice público (1 linha por entidade) | `v2_public_search_index` | `PublicIndexerService::index` | `V2/Services/Search/Services/PublicIndexerService.php:21-57` |
| Recálculo do vetor com pesos A/B/C | coluna `search_vector` (`tsvector`) | trigger `v2_public_search_vector_trigger` | `V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:63-79` |
| Upsert do índice administrativo | `v2_cms_links_index` | `CMSIndexerService::index` | `V2/Services/Search/Services/CMSIndexerService.php:21-50` |
| Remoção silenciosa em falha de indexação | log de erro; **nada** é lançado | `catch (Exception $e) { Log::error(...) }` | `V2/Services/Search/Services/PublicIndexerService.php:54-56`; `CMSIndexerService.php:47-49` |
| Criação automática de linhas para BasicPage no boot de console | `v2_public_search_index` / `v2_cms_links_index` | varredura de páginas não indexadas | `V2/Core/Providers/V2ServiceProvider.php:147-162` |
| Reindexação em lote (4 grupos) | ambos os índices, via `syncToSearch()` | `ReindexPublicSearch` | `V2/Services/Search/Console/Commands/ReindexPublicSearch.php:25-134` |
| Cache de página de 5 min para `/busca` | Redis (`page:<sha1(fullUrl)>`) | `PageCacheMiddleware` | `V2/Core/Middleware/PageCacheMiddleware.php:11-22,31-56` |
| Cabeçalho HTTP `Cache-Control: public, max-age=60` em `/busca` | resposta | `PublicCacheMiddleware` | `V2/Core/Providers/V2ServiceProvider.php:262-266` |

Não há gravação de histórico de buscas, nem contador de acessos, nem evento de domínio. O `SEOService` registra visitas em outra tabela (`v2_seo_page_visits`), por outro middleware.

### 1.3 Contrato observável

| Chamada | Resultado |
|---|---|
| `publicSearch('')`, `publicSearch('   ')`, `publicSearch(null)` | `collect()` vazio; a página renderiza “Nenhum resultado encontrado” (`SearchService.php:22-24`) |
| `publicSearch('noticia')` | até 20 resultados ordenados por `rank` desc, `created_at` desc |
| `publicSearch(['x'])` (query `q[]=x`) | `trim(array)` emite `E_WARNING` no PHP 7.4; o handler do Laravel converte **todo warning** em `ErrorException` (via `set_error_handler` com `error_reporting(-1)`), e a resposta é **500** (`SearchController.php:32`; `SearchService.php:22`; `vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:34,57-60`). Em PHP 8+ seria `TypeError`, também 500 |
| `cmsSearch($term)` sem usuário | apenas resultados do banco (o bloco de menus é pulado, `SearchService.php:104-107`) |
| `cmsSearch($term)` com usuário | menus/permissões filtrados + até 10 linhas de `v2_cms_links_index`, truncados juntos em 10 (`SearchService.php:232-234`) |
| `GET /cms/search` com `X-Requested-With: XMLHttpRequest` | `200` envelope `{ok:true,data:{term,results},message:"Busca realizada com sucesso.",error:null}` (`V2/Core/Controllers/SearchController.php:49-54`, `V2/Core/Traits/ApiResponse.php:10-18`) |
| `GET /cms/search` sem AJAX | `200` HTML `v2-cms::search.index` |
| `GET /busca` | `200` HTML `v2-portal::search.index` |
| `php artisan v2:reindex-public --only=foo` | nada é reindexado, imprime “Reindexação pública concluída em 0s.” e retorna `0` — não há validação do valor (`ReindexPublicSearch.php:27-50`) |

### 1.4 O que o serviço deliberadamente NÃO faz

- **Não filtra por visibilidade/regional.** A consulta não toca `unidade_regional_id`, não usa `RegionalScopeService` e não distingue tenant: todo conteúdo indexado é elegível para qualquer visitante (`SearchService.php:26-41`).
- **Não usa `tags` na busca.** `tags` é gravada (`PublicIndexerService.php:24,29,38,51`) e nunca lida por `publicSearch` nem por `cmsSearch`; só entra no ranking quando o model também a repete em `search_deep_content`.
- **Não pagina.** `limit(20)` fixo no portal e `slice(0, 10)` no CMS; a view apenas informa “Exibindo N resultados mais relevantes” (`V2/layouts/portal/search/index.blade.php:58-60`).
- **Não ordena por data escolhida pelo usuário**, não aceita filtro por módulo/tipo/data e não expõe sugestões (“você quis dizer”).
- **Não indexa automaticamente por observer genérico.** Índice é alimentado por hooks escritos à mão em cada model e por um único `SearchSender` (`V2/Modules/Publicacoes/Noticias/Services/NewsSearchSender.php`).
- **Não remove órfãos.** Não existe rotina de reconciliação; um `delete` que falhe deixa a linha no índice para sempre.
- **Não autoriza nada no portal**, e no CMS apenas filtra menus; o acesso à rota é do `CmsAuthenticate` (`V2/Core/Routes/cms.php:20-27`).

### 1.5 Efeito por perfil

| Perfil | O que vê |
|---|---|
| Público | `/busca?q=`, 20 resultados, sem qualquer indicação de módulo oculto; pode receber conteúdo de qualquer regional |
| Usuário CMS comum | `/cms/search` com menus filtrados por `PermissionService::getUserPermissions` (`SearchService.php:115-136`) e registros de índice, sem filtro por permissão do registro |
| `super-admin` / `config('v2.support.email')` | ignora RBAC nos menus e enxerga o grupo `SUPPORT` (`SearchService.php:127-136`) |
| Operador regional | **sem diferença** de um usuário global na busca; o escopo regional só existe nas listagens dos módulos |
| API | não há rota de busca em `V2/Core/Routes/api.php`; o único JSON é o envelope AJAX de `/cms/search` |

## 2. Abordagem

### 2.1 Estratégia e por quê

Dois índices com propósitos distintos, ambos em PostgreSQL e ambos escritos por UPSERT idempotente — decisão explícita do README (“`INSERT ... ON CONFLICT DO UPDATE` para indexação atômica”, `V2/Services/Search/README.md:7-9`):

- **`v2_public_search_index`** — projeção de leitura pública. Guarda `title`, `slug`, `module`, `entity_type`, `entity_id`, `summary_fragment`, `search_deep_content`, `tags` e um `tsvector` derivado por trigger. A chave de idempotência é `(entity_type, entity_id)`, o que permite reindexar sem duplicar (`V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:43-58`).
- **`v2_cms_links_index`** — “Search-to-Edit”. Não guarda entidade, guarda **destino de edição**: `(module, edit_url)` é a chave, `label` é o texto exibido e `searchable_text` é o que entra no GIN (`:28-40`).

O vetor é materializado no banco (e não em código PHP) porque `ts_rank` e `ts_headline` precisam dele em tempo de consulta, e o GIN `v2_public_search_vector_idx` só é usado se a coluna for real (`:83`). Pesos `A=title`, `B=summary`, `C=deep` fazem um acerto no título valer mais que um acerto no corpo.

```mermaid
flowchart LR
  subgraph Escrita
    M[Model Eloquent<br/>saved / deleted] --> S[syncToSearch]
    S --> D1[PublicSearchDTO]
    S --> D2[CMSIndexDTO]
    D1 --> PI[PublicIndexerService::index]
    D2 --> CI[CMSIndexerService::index]
    PI -->|UPSERT entity_type+entity_id| T1[(v2_public_search_index)]
    CI -->|UPSERT module+edit_url| T2[(v2_cms_links_index)]
    T1 -->|trigger BEFORE INSERT/UPDATE| V[search_vector tsvector]
  end
  subgraph Leitura
    Q1[GET /busca?q=] --> SS[SearchService::publicSearch]
    Q2[GET /cms/search?q=] --> SC[SearchService::cmsSearch]
    SS -->|@@ plainto_tsquery| T1
    SC --> T2
    SC -->|stripos na memória| CFG[v2.modules.configs]
  end
```

### 2.2 Mecânica passo a passo

**A. Indexação (por entidade, em `saved`)**

1. `Model::saved` chama `syncToSearch()` (ex.: `V2/Modules/Publicacoes/Noticias/Models/News.php:292-296`).
2. `News::syncToSearch()` delega a `NewsSearchSender::sync($news)` (`News.php:376-382`), que decide: publicado → `syncPublic`, senão `removeFromPublic`; e **sempre** `syncCms` (`V2/Modules/Publicacoes/Noticias/Services/NewsSearchSender.php:29-40`).
3. O sender monta o DTO e chama `app(PublicIndexerService::class)->index($dto)` / `app(CMSIndexerService::class)->index($dto)`.
4. `PublicIndexerService::index` faz `json_encode($dto->tags)`, gera `id` com `v2_uuid()` e executa SQL cru com `ON CONFLICT (entity_type, entity_id)`.
5. O trigger `v2_public_search_vector_trigger` recalcula `search_vector` na mesma transação do `INSERT`/`UPDATE`.

**B. Busca portal (`SearchController@portalSearch`)**

1. `$term = $request->get('q', '')` (`V2/Core/Controllers/SearchController.php:32`).
2. `SearchService::publicSearch($term)` retorna cedo se vazio.
3. SQL: `ts_headline` + `ts_rank` + `@@ plainto_tsquery('portuguese', ?)`, ordenação por `rank` e `created_at`, `limit(20)`.
4. Cada linha recebe `result_url` via `publicResultUrl($module, $slug, $entity_id)`; linhas cujo `module` não mapeia caem no `default` (`/` + slug), exceto parceiros sem `entity_id`, que são **descartadas** (`SearchService.php:46-50`).
5. A view renderiza badge `strtoupper($module)`, título, `headline` **sem escape** (`{!! $item->headline !!}`) e “Relevância: rank*100%” (`V2/layouts/portal/search/index.blade.php:36-47`).

**C. Busca CMS (`SearchController@cmsSearch`)**

1. Mesmo guard de vazio; depois busca **em memória** nos menus (`v2.modules.configs`), aplicando `ModuleFlagService::filterActive` e RBAC por nome de módulo (`SearchService.php:108-136`).
2. Para cada grupo/módulo/submódulo cujo `title` contenha o termo via `stripos`, cria item `{label, module:'Menu', edit_url, highlight}` com prefixos textuais fixos (“Grupo administrativo: ”, “Ir para o painel de: ”, “Aba administrativa: ”, “Sub-recurso: ”).
3. Consulta `v2_cms_links_index` com `to_tsvector('portuguese', COALESCE(searchable_text,'')) @@ plainto_tsquery(...)` e `ts_headline` (`:218-229`).
4. Concatena `array_merge($menuResults, $dbResults)` e corta em 10 (`:232-234`) — menus primeiro.
5. Controller devolve JSON quando `ajax()`/`wantsJson()`, senão a view de tabela (`SearchController.php:49-59`).

### 2.3 Trechos de código pivô

```php
// V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:63-79 — o vetor é
// derivado no banco, com pesos por campo; nenhum código PHP escreve search_vector.
DB::statement("
    CREATE OR REPLACE FUNCTION v2_public_search_vector_update() RETURNS trigger AS $$
    BEGIN
        NEW.search_vector :=
            setweight(to_tsvector('portuguese', COALESCE(NEW.title, '')), 'A') ||
            setweight(to_tsvector('portuguese', COALESCE(NEW.summary_fragment, '')), 'B') ||
            setweight(to_tsvector('portuguese', COALESCE(NEW.search_deep_content, '')), 'C');
        RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
");
```
Mostra a única fonte do texto pesquisável: `tags` não entra no vetor, e não há `unaccent` — “noticia” não casa com “notícia”.

```php
// V2/Services/Search/Services/PublicIndexerService.php:27-45 — UPSERT idempotente por (entity_type, entity_id).
$sql = "
    INSERT INTO v2_public_search_index
    (id, title, slug, module, entity_type, entity_id, summary_fragment, search_deep_content, tags, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
    ON CONFLICT (entity_type, entity_id)
    DO UPDATE SET
        title = EXCLUDED.title,
        slug = EXCLUDED.slug,
        module = EXCLUDED.module,
        summary_fragment = EXCLUDED.summary_fragment,
        search_deep_content = EXCLUDED.search_deep_content,
        tags = EXCLUDED.tags,
        updated_at = NOW();
";
DB::statement($sql, [$id, $dto->title, $dto->slug, $dto->module, $dto->entity_type,
    $dto->entity_id, $dto->summary, $dto->search_deep_content, $tags]);
```
O `id` gerado (`v2_uuid()`) é descartado no conflito; `created_at` permanece o da primeira indexação — é ele que desempata resultados de mesmo `rank`.

```php
// V2/Services/Search/Services/SearchService.php:26-41 — a query pública inteira: 20 linhas, sem filtro de módulo.
$results = DB::table('v2_public_search_index')
    ->select([
        'title', 'slug', 'module', 'entity_id',
        DB::raw("ts_headline('portuguese', summary_fragment, plainto_tsquery('portuguese', ?)) as headline"),
        DB::raw("ts_rank(search_vector, plainto_tsquery('portuguese', ?)) as rank"),
    ])
    ->whereRaw("search_vector OPERATOR(pg_catalog.@@) plainto_tsquery('portuguese', ?)")
    ->setBindings([$term, $term, $term])
    ->orderByDesc('rank')
    ->orderByDesc('created_at')
    ->limit(20)
    ->get()
    ->toArray();
```
`setBindings` posicional casa com a ordem `headline` → `rank` → `where`. `ts_rank` sem flag de normalização; `plainto_tsquery` faz AND entre os tokens.

```php
// V2/Services/Search/Services/SearchService.php:68-89 — a URL pública é um switch sobre o campo module.
public static function publicResultUrl($module, $slug, $entityId = null)
{
    switch ($module) {
        case 'noticias':                 return url('/noticia/' . $slug);
        case 'Eventos':                  return url('/agenda/' . $slug);
        case 'unidades-regionais':       return url('/regional/' . $slug);
        case 'associados':               return url('/associado/' . $slug);
        case 'trabalhe-conosco':         return url('/trabalhe-conosco/' . $slug);
        case 'diretoria':                return url('/equipe/' . $slug);
        case 'parceiros-institucionais':
            return empty($entityId) ? null : url('/parceiros-institucionais/detalhe/' . $entityId);
        case 'basicpage':
        default:                         return url('/' . $slug);
    }
}
```
Contrato frágil: `module` é string livre na tabela, gravada por cada model; qualquer grafia nova cai no `default` e gera `/{slug}`. Só parceiros usa `entity_id`; as rotas reais confirmam os URIs (`V2/Modules/Conexoes/ParceirosInstitucionais/Routes/web.php:12`).

```php
// V2/Services/Search/Services/SearchService.php:218-234 — busca no índice do CMS e truncamento conjunto.
$dbResults = DB::table('v2_cms_links_index')
    ->select(['label', 'module', 'edit_url',
        DB::raw("ts_headline('portuguese', searchable_text, plainto_tsquery('portuguese', ?)) as highlight")])
    ->whereRaw("to_tsvector('portuguese', COALESCE(searchable_text, '')) OPERATOR(pg_catalog.@@) plainto_tsquery('portuguese', ?)")
    ->setBindings([$term, $term])
    ->limit(10)
    ->get()
    ->toArray();

$merged = array_merge($menuResults, $dbResults);
return collect($merged)->slice(0, 10);
```
`$menuResults` vem primeiro: 10 menus que casem o termo zeram os registros — “Search-to-Edit” degrada para “busca de menu”. A consulta usa `to_tsvector` na hora (não há coluna materializada), mas o GIN `v2_cms_links_search_idx` cobre a mesma expressão (`V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:81`).

```php
// V2/Core/Providers/V2ServiceProvider.php:147-162 — no console, o boot indexa BasicPages ausentes no índice.
if (class_exists(\V2\Modules\Layouts\BasicPage\Models\BasicPage::class)
    && $this->app->runningInConsole()
    && \Illuminate\Support\Facades\Schema::hasTable('v2_basic_pages')
    && \Illuminate\Support\Facades\Schema::hasTable('v2_public_search_index')
) {
    try {
        $unsynced = \V2\Modules\Layouts\BasicPage\Models\BasicPage::whereNotExists(function ($query) {
            $query->select(\Illuminate\Support\Facades\DB::raw(1))
                   ->from('v2_public_search_index')
                   ->whereRaw('v2_public_search_index.entity_id = v2_basic_pages.id');
        })->get();
        foreach ($unsynced as $page) { $page->syncToSearch(); }
    } catch (\Exception $e) { /* Log::warning */ }
}
```
É o único “backfill” automático: roda em **todo** comando artisan, sem `LIMIT`, e ignora `entity_type` na comparação (só `entity_id`).

### 2.4 Algoritmos e custos

| Operação | Custo | Observação |
|---|---|---|
| `publicSearch` | 1 consulta; GIN scan + `ts_rank` sobre até 20 linhas devolvidas | `ts_headline` é aplicado a cada linha do resultado, não ao índice |
| `cmsSearch` | 1 consulta ao índice + N chamadas `Route::has()`/`route()` (uma por grupo, módulo e submódulo visitado) | Percorre todos os `v2.modules.configs` em PHP a cada busca |
| `CMSIndexerService::index` | 1 statement + `json_encode` | `searchable_text` sem truncamento (BasicPage envia `strip_tags(content)` inteiro) |
| `PublicIndexerService::index` | 1 statement + trigger por linha | Trigger recalcula `to_tsvector` três vezes |
| `v2:reindex-public` | `chunk(100)`, 1 SELECT + N `syncToSearch` (2–4 statements cada) | Sem transação global; falhas são logadas e a contagem local mostra sucessos, não o total |
| `/busca` | cache de página 5 min por URL (`fullUrl`, inclui `q`) | Mudanças no índice podem demorar até 5 min para aparecer |

## 3. Particularidades

### 3.1 O que é exclusivo deste serviço

- **Fonte única do mapeamento módulo→URL pública**: qualquer consumidor que precise transformar um `module` do índice em link usa `publicResultUrl`. Nem os módulos nem o `SEOService` duplicam esse mapa.
- **Fonte única do `search_vector`**: a coluna nasce de trigger, não de model; reimplementar sem trigger exige escrever o vetor no `INSERT`/`UPDATE` ou aceitar índices desatualizados.
- **Segundo índice com chave de URL de edição** (`module, edit_url`): o CMS indexa “destino”, não entidade — por isso o mesmo registro pode ter várias linhas se a URL de edição mudar (ex.: rota com `?tab=`).

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-SEARCH-01 | `publicSearch` limita a 20 e não pagina | `total` real nunca é informado; a view diz “Exibindo N” | `SearchService.php:39`; view `:58-60` |
| RN-SEARCH-02 | `cmsSearch` limita o **conjunto** a 10, menus primeiro | registros do banco somem quando há 10 menus | `SearchService.php:232-234` |
| RN-SEARCH-03 | `entity_type` é `get_class($model)` em 5 entidades e string curta em 2 (`'evento'`, `'board_member'`) | renomear a classe/namespace cria nova linha e deixa órfã | `Evento.php:228`; `BoardMember.php:243`; `NewsSearchSender.php:110` |
| RN-SEARCH-04 | `module` é string arbitrária; a URL depende dela | `module` divergente → link `/{slug}` silencioso | `SearchService.php:70-88` |
| RN-SEARCH-05 | Índice público só recebe entidade ativa/publicada | despublicar remove a linha (via hook), mas só quando o `save()` passa pelo Eloquent | `NewsSearchSender.php:29-40`; `Evento.php:223-239` |
| RN-SEARCH-06 | Falha de indexação é engolida (`try/catch` + `Log::error`) | `save()` nunca falha por causa da busca; índice pode divergir sem alerta | `PublicIndexerService.php:54-56` |
| RN-SEARCH-07 | `timestampsTz` no índice, `NOW()` no UPSERT | `created_at` imutável no conflito; usado como desempate | `PublicIndexerService.php:39`; migration `:55` |
| RN-SEARCH-08 | Busca é sensível a acento e a caixa de tokens (`portuguese` sem `unaccent`) | “noticia” não acha “notícia”; “Notícias” casa por stemming do dicionário pt | migration `:63-73` (nenhum `unaccent`) |
| RN-SEARCH-09 | Tags não são pesquisáveis por si | regional só aparece porque o nome/sigla/UF é concatenado em `search_deep_content` | `NewsSearchSender.php:95-104` |

### 3.3 Casos-limite

| Caso | Comportamento | Fonte |
|---|---|---|
| `q` vazio/só espaços | `200`, `collect()` vazio | `SearchService.php:22-24` |
| `q` como array (`q[]=x`) | `trim(array)` → `E_WARNING` no PHP 7.4; `HandleExceptions::handleError` lança `ErrorException` → **500**. O caminho `empty(null)`→`200` só ocorreria com warnings silenciados. Em PHP 8+ é `TypeError`/500. `[C]` | `SearchController.php:32`; `SearchService.php:22`; `vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:34,57-60` |
| índice vazio ou tabela ausente antes da migration | `QueryException` não capturada em `publicSearch` → `500` | `SearchService.php:26-41` (sem `try/catch`) |
| `summary_fragment` nulo | `ts_headline` devolve `null`; view imprime vazio | `SearchService.php:32`; migration `:51` |
| resultado de parceiro sem `entity_id` | linha descartada do resultado (`continue`) | `SearchService.php:46-50,83-84` |
| `module` desconhecido | link `/{slug}` (ou 404 se a slug não existir) | `SearchService.php:85-87` |
| linha órfã após `delete` falho | continua aparecendo para sempre | ausência de reconciliação (LG-SEARCH-01) |
| conteúdo publicado → despublicado por `query builder` | hook não roda; linha permanece | `NewsSearchSender.php:29-40` |
| busca CMS com mais de 10 menus casando | registros de banco não aparecem | `SearchService.php:234` |
| BasicPage com `content` grande | `searchable_text` e vetor crescem sem limite; GIN maior | `BasicPage.php:236-240` |

### 3.4 Armadilhas de reimplementação

1. **Ordem de bindings**: em `publicSearch`, os `?` do `select` vêm antes do `?` do `where`; `setBindings` substitui **todos** os bindings. Se você adicionar um `where` após o `setBindings`, o termo vai para a posição errada e a consulta retorna lixo ou erro.
2. **Trigger vs. aplicação**: remover o trigger e escrever o vetor no PHP muda o resultado de `ts_rank` (pesos e dicionário podem divergir) e quebra qualquer `UPDATE` feito fora do indexer.
3. **`entity_type` como chave**: usar FQCN amarra o índice ao namespace do model. Se a reimplementação renomear classes (ex.: `App\Models\News`), as linhas antigas ficam órfãs e o UPSERT cria duplicatas lógicas.
4. **`slice(0,10)` depois do merge**: inverter a ordem (banco antes de menus) muda completamente o que o usuário vê; é uma decisão de produto, não um detalhe.
5. **Cache de página**: `/busca?q=x` fica 5 min em Redis; testar indexação sem limpar `page:*` produz falso negativo.
6. **`updateOrCreate` ingênuo**: o índice é escrito com SQL cru; um `Model::updateOrCreate` sobre a tabela falha porque `id` não é `fillable` e porque não há model algum para essas tabelas.
7. **`to_tsvector` sem `pg_catalog`**: a consulta usa `OPERATOR(pg_catalog.@@)` para não colidir com operadores de extensões; omitir isso pode resolver para outro `@@`.

### 3.5 Dívidas e divergências

| Divergência | README diz | Código faz | Fonte |
|---|---|---|---|
| Mecanismo de indexação | “Explícito: sem abstrações automáticas (traits/observers)” e “chame o utilitário no Controller” | 8 models chamam `syncToSearch` em `static::saved`/`static::deleted`; só Notícias usa um `SearchSender` | `V2/Services/Search/README.md:9,35-46`; `News.php:292-331` |
| Versão do PostgreSQL | migration “PG 10+” | README “PostgreSQL 17 Native” | migration `:12`; README `:7` |
| `Consumer/SearchSender.php` por módulo | README manda criar em `Consumer/` | o único existente é `Services/NewsSearchSender.php` | README `:14-16`; `NewsSearchSender.php:1-16` |
| Rollback | — | `down()` **renomeia** as tabelas para `backup_*` em vez de dropar; rodar `migrate` de novo recria vazias | migration `:98-123` |
| Migration de UI no serviço | — | `2026_07_24_000001_fix_search_overlay_input_name.php` altera `v2_structural_portal_contents` (tabela do módulo SLE), embora o seeder atual já gere `name="q"` | migration `:49-51`; `V2/Seeds/SleHeaderSeeder.php:187` |
| Reindexação | comando cobre “índice público” | reindexa 4 grupos; **não** reindexa Eventos, Trabalhe Conosco, Parceiros e Diretoria | `ReindexPublicSearch.php:20-45` |
| `use` não utilizado | — | `ReindexPublicSearch` importa `PublicIndexerService` e não usa | `ReindexPublicSearch.php:10` |
| Envelope AJAX | página CMS funciona | JS lê `data.results`, mas o controller devolve `data.data.results` | `public/assets/js/cms-layout.js:112,122`; `ApiResponse.php:12-17` |

## 4. Integrabilidade

### 4.1 API pública

| Símbolo | Assinatura | Retorno | Exceções | Consumidores |
|---|---|---|---|---|
| `SearchService::publicSearch` | `publicSearch($term)` | `Illuminate\Support\Collection` de `stdClass` com `title, slug, module, entity_id, headline, rank, result_url` | `TypeError` (termo não-string), `QueryException` | `SearchController@portalSearch` (`SearchController.php:33`) |
| `SearchService::cmsSearch` | `cmsSearch($term)` | `Collection` de arrays `{label, module, edit_url, highlight}` | idem | `SearchController@cmsSearch` (`:47`) |
| `SearchService::publicResultUrl` | `static publicResultUrl($module, $slug, $entityId = null)` | `string|null` | — | `SearchService::publicSearch` (`:46`); candidatos externos |
| `PublicIndexerService::index` | `index(PublicSearchDTO $dto)` | `void` (nunca lança — engole `Exception`) | — | `NewsSearchSender`, `BasicPage`, `Evento`, `AssociadoInstituicao`, `UnidadeRegional`, `Parceiro`, `JobOpening`, `BoardMember` |
| `PublicIndexerService::remove` | `remove($entityType, $entityId)` | `void` | `QueryException` (sem `try/catch`) | hooks `deleted` dos 8 models |
| `CMSIndexerService::index` | `index(CMSIndexDTO $dto)` | `void` (engole `Exception`) | — | `NewsSearchSender`, `BasicPage`, `BoardMember` |
| `CMSIndexerService::remove` | `remove($module, $editUrl)` | `void` | `QueryException` | `BoardMember::deleted` (uso incorreto, ver 3.5) |
| `PublicSearchDTO::__construct` | `($title, $slug, $module, $entity_type, $entity_id, $summary = '', $tags = [], $search_deep_content = '')` | instância | — | 8 models |
| `CMSIndexDTO::__construct` | `($module, $edit_url, $label, $searchable_text = '', $metadata = [])` | instância | — | 3 models |
| `PublicSearchDTO::fromArray` / `CMSIndexDTO::fromArray` | `(array $data)` | instância | `Undefined index` nas chaves obrigatórias | nenhum consumidor no V2 |
| Comando | `php artisan v2:reindex-public {--only=news|associados|unidades|basic-pages|all}` | `int` (0) | exceções dos models são logadas e contidas | operador |

### 4.2 Componentes Blade e contrato de UI

Não se aplica ao diretório do serviço: `V2/Services/Search/` **não** tem `Views/`, logo não publica o namespace `v2-service-search::` (o provider só registra views se `Views/` existir, `V2/Core/Providers/V2ServiceProvider.php:181-185`). As duas telas pertencem aos layouts globais:

| View | Namespace | Variáveis obrigatórias | Efeito |
|---|---|---|---|
| `V2/layouts/portal/search/index.blade.php` | `v2-portal::search.index` | `$term` (string), `$results` (Collection) | lista cards; usa `$item->module`, `$item->title`, `$item->headline` (sem escape), `$item->rank`, `$item->result_url` |
| `V2/layouts/cms/search/index.blade.php` | `v2-cms::search.index` | `$term`, `$results` | tabela Módulo/Rótulo/Contexto/Ação; usa `$item->label`, `$item->highlight`, `$item->edit_url`; `strip_tags($item->highlight, '<b><i><em><strong><br>')` |

O overlay do header público é markup estático (`V2/layouts/portal/header/header-search-area.blade.php:5-40`): `<search role="search">`, botão `.js-toggle-search` com `aria-expanded`, `<div id="search-dropdown">`, `<input id="portal-search" name="q" required>` e `<form action="{{ url('busca') }}" method="GET">`. Os IDs de DOM são contrato do CSS/JS (`.search-dropdown-menu`, `.search-dropdown-menu.d-none` → aberto por `js-toggle-search`).

### 4.3 JS e ciclo de vida

- **Portal**: o `<form>` faz submit clássico (GET); o único JS do header é `header-search-area.blade.php:88-102`, que fecha o dropdown ao rolar com viewport &lt; 992 px. Sem JS, o botão não abre o dropdown (classe `d-none` fixa).
- **CMS**: `public/assets/js/cms-layout.js:74-172` implementa o modal `#spotlightSearchModal`, aberto por `Ctrl/Cmd+K` (`:35`) ou `openSpotlightSearch()` (`V2/layouts/cms/layouts/header.blade.php:10`). O input `#spotlightSearchInput` dispara `input` com debounce de 250 ms, exige ≥ 2 caracteres, faz `fetch(window.cmsSearchUrl + '?q=' + encodeURIComponent(query))` com `X-Requested-With: XMLHttpRequest` e `Accept: application/json`, renderiza em `#spotlightResultsContainer` e navega por setas/Enter.
- **Contrato quebrado**: o JS lê `data.results` e `data.results.length` (`cms-layout.js:112,122`), mas o envelope é `{ok, data:{term, results}}` (`ApiResponse.php:12-18`). Resultado: mesmo com `200` e resultados no corpo, a UI sempre cai em “Nenhum resultado encontrado para…”. A página HTML `/cms/search?q=` continua funcionando.

### 4.4 Ordem de inicialização e registro

1. Migration `2026_06_14_000000` precisa rodar **depois** de `V2/Core/Migrations/2026_06_01_000001_create_v2_uuid_function.php` (usa `v2_gen_uuid()` como default) e antes de qualquer `save()` de entidade indexada.
2. `V2ServiceProvider::register/boot` carrega as migrations de **todos** os diretórios de `V2/Services/*` (`V2/Core/Providers/V2ServiceProvider.php:176-180`), portanto `V2/Services/Search/Migrations` entra sem registro explícito.
3. Rotas: `/busca` é registrada por `mapCoreRoutes()` com `web, MaintenanceMiddleware, page.cache, public.cache` (`:256-269`); `/cms/search` é declarada em `V2/Core/Routes/cms.php:27` dentro do grupo `cms` com `CmsAuthenticate`.
4. Comando: registro **manual** em `app/Console/Kernel.php:15-23` (Laravel 5.5 não faz auto-discovery).
5. Nenhum provider, facade, alias ou singleton é registrado: os serviços são resolvidos por `app()` (container autowiring).
6. `app('v2.modules.configs')` é populado no boot (`V2ServiceProvider.php:133`); `cmsSearch` depende disso e, se ausente, devolve `[]` (`SearchService.php:108`).

### 4.5 Receitas de uso

**1. Indexar uma entidade nova (padrão Notícias, o único `SearchSender` existente):**

```php
// dentro do model, nos hooks de ciclo de vida
static::saved(function ($model) {
    try { $model->syncToSearch(); }
    catch (\Exception $e) { \Log::error('[MeuModulo] Falha ao sincronizar busca: ' . $e->getMessage()); }
});
static::deleted(function ($model) {
    app(\V2\Services\Search\Services\PublicIndexerService::class)
        ->remove(get_class($model), $model->id);
});
```
Resultado esperado: uma linha em `v2_public_search_index` com `entity_type = get_class($model)`; `remove` usa exatamente o mesmo par.

**2. Indexar manualmente um registro (sem model):**

```php
$dto = new \V2\Services\Search\DTOs\PublicSearchDTO(
    'Título', 'slug-do-item', 'meu-modulo', 'meu_tipo', $uuid,
    'resumo curto', ['tag1', 'tag2'], 'texto profundo que será pesquisado'
);
app(\V2\Services\Search\Services\PublicIndexerService::class)->index($dto);
```
Resultado: upsert por `(meu_tipo, $uuid)`; o `module` precisa ser conhecido por `publicResultUrl`, senão o link público vira `/{slug}`.

**3. Consumir a busca pública fora do controller:**

```php
$resultados = app(\V2\Services\Search\Services\SearchService::class)->publicSearch('associados');
foreach ($resultados as $r) {
    echo $r->title . ' -> ' . $r->result_url; // result_url já resolvido
}
```

**4. Reindexar após mudar a lógica de indexação:**

```bash
php artisan v2:reindex-public --only=news
php artisan v2:reindex-public            # news + associados + unidades + basic-pages
```
Resultado: `Notícias reindexadas: N` por grupo e “Reindexação pública concluída em Ns.”; não há dry-run nem contagem de falhas.

**5. Navegar pelo CMS (spotlight):** `Ctrl+K` → digitar ≥2 caracteres → `GET /cms/search?q=...` (AJAX). Enquanto o bug de envelope (4.3) existir, use a página `/cms/search?q=...` diretamente.

### 4.6 Anti-uso

1. **Escrever direto em `v2_public_search_index` sem `search_vector`**: um `INSERT` via query builder ainda dispara o trigger, mas um `UPDATE` em massa que tente setar `search_vector` manualmente com dicionário diferente do trigger corrompe o ranking e o GIN. Use os indexers.
2. **Chamar `CMSIndexerService::remove($module, $id)`** (como faz `BoardMember::deleted`, `BoardMember.php:111`): o segundo argumento é `edit_url`, não `id`; nada é removido e a entrada órfã fica para sempre no Search-to-Edit. Remova pela URL exata de edição.
3. **Confiar em `score`/`rank` absoluto**: `ts_rank` sem normalização produz valores pequenos e dependentes do tamanho do documento; a view só multiplica por 100 (`portal/search/index.blade.php:39`). Não use o número como percentual real.
4. **Indexar no `saving`**: o `id` ainda não existe em `creating` para models sem UUID no PHP, e `saved` é o único ponto em que `id`, `slug` e `status` estão persistidos. Todos os models usam `saved`.

### 4.7 Custos e limites

| Limite | Valor | Fonte |
|---|---|---|
| Resultados do portal | 20 | `SearchService.php:39` |
| Resultados do CMS | 10 (menus + registros) | `SearchService.php:227,234` |
| Caracteres mínimos (CMS, AJAX) | 2 (client-side) | `public/assets/js/cms-layout.js:83-91` |
| Debounce do spotlight | 250 ms | `cms-layout.js:98,147` |
| Cache de página | 300 s, chave `page:<sha1(fullUrl)>` | `V2/Core/Middleware/PageCacheMiddleware.php:15-17` |
| Cache HTTP público | `max-age=60` | `PublicCacheMiddleware` (via `mapCoreRoutes`) |
| Chunk de reindexação | 100 | `ReindexPublicSearch.php:59,79,99,123` |
| Throttle | não há | `V2/Core/Routes/web.php:14` |

## 5. Rotas e endpoints

| Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|
| GET | `/busca` | `v2.portal.search` | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | `SearchController@portalSearch` | pública | Renderiza até 20 resultados; `q` ausente → página vazia | `V2/Core/Routes/web.php:14`; `V2/Core/Providers/V2ServiceProvider.php:262-266` |
| GET | `/cms/search` | `v2.cms.search` | `web`, `CmsAuthenticate` | `SearchController@cmsSearch` | autenticado; menus filtrados por RBAC | HTML da tabela ou JSON `{ok,data:{term,results}}` | `V2/Core/Routes/cms.php:27`; `SearchController.php:44-59` |

Parâmetros: apenas `q` (query string, opcional). Não há paginação, ordenação, filtro ou `per_page`.

## 6. Dados e migrations

### 6.1 `v2_public_search_index`

| Coluna | Tipo | Nulo/default | Papel |
|---|---|---|---|
| `id` | `uuid` PK | default `v2_gen_uuid()` (na prática o PHP envia `v2_uuid()`) | identidade da linha |
| `title` | `string` | não nulo | peso A do vetor; exibido |
| `slug` | `string` | não nulo | usado na URL pública |
| `module` | `string` | não nulo | chave do `switch` de URL e do badge |
| `entity_type` | `string` | não nulo | metade da chave de idempotência (FQCN ou sigla) |
| `entity_id` | `uuid` | não nulo | outra metade da chave; usado por parceiros na URL |
| `summary_fragment` | `text` | nulo | peso B; fonte do `ts_headline` |
| `search_deep_content` | `text` | nulo | peso C |
| `tags` | `jsonb` | nulo | **não pesquisável**; sem leitor |
| `search_vector` | `tsvector` | nulo (preenchido pelo trigger) | índice GIN e ranking |
| `created_at`/`updated_at` | `timestampTz` | nulos | desempate de ranking |

Índices: `v2_public_search_vector_idx` (GIN em `search_vector`), unique `(entity_type, entity_id)`. Sem FK, sem soft delete. Migration `V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:42-83`.

### 6.2 `v2_cms_links_index`

| Coluna | Tipo | Nulo/default | Papel |
|---|---|---|---|
| `id` | `uuid` PK | default `v2_gen_uuid()` | identidade |
| `label` | `string` | não nulo | texto principal da lista |
| `module` | `string` | não nulo | badge + metade da chave |
| `edit_url` | `string` | não nulo | destino do botão “Editar”; outra metade da chave |
| `searchable_text` | `text` | nulo | único campo vetorizado |
| `metadata` | `jsonb` | nulo | hoje só `{status}` de Notícias |
| `created_at`/`updated_at` | `timestampTz` | nulos | — |

Índices: `v2_cms_links_search_idx` (GIN em `to_tsvector('portuguese', COALESCE(searchable_text,''))`), unique `(module, edit_url)`. Migration `:27-40,81`.

### 6.3 Migration de correção de UI

`2026_07_24_000001_fix_search_overlay_input_name.php` não toca nas tabelas de índice: faz `REPLACE` no HTML de `v2_structural_portal_contents` (`area='header'`) para inserir `name="q"` e trocar `action="/busca"` por `{{ route('v2.portal.search') }}` (`:49-56,70-83`), com log `[FixSearchOverlayInputName]`. `down()` apenas registra que rollback não é suportado (`:33-36`).

## 7. Configuração e permissões

| Chave | Default | Efeito | Consumidores | Fonte |
|---|---|---|---|---|
| `v2.support.email` | `env('V2_SUPPORT_EMAIL', 'suporte@maxima.inf.br')` | libera o grupo `SUPPORT` e ignora RBAC na busca CMS | `SearchService.php:127,133` | `config/v2.php:4-6` |

Não há chave de config própria de busca (limites 20/10, dicionário `'portuguese'` e store de cache são literais no código). Permissões: `/busca` é pública; `/cms/search` exige `CmsAuthenticate` (ao menos um papel Sentinel, `V2/Core/Routes/cms.php:20-27`) e o filtro por módulo é aplicado **dentro** do serviço, com chave igual ao nome do diretório do módulo (`SearchService.php:133-136`).

## 8. Observabilidade e erros

| Situação | Log/efeito | Fonte |
|---|---|---|
| Falha no upsert público | `Log::error('[Search] Public Indexing failed: ' . $e->getMessage())`; `save()` continua | `PublicIndexerService.php:54-56` |
| Falha no upsert CMS | `Log::error('[Search] CMS Indexing failed: ...')` | `CMSIndexerService.php:47-49` |
| Falha no reindex por item | `Log::error('[ReindexPublicSearch] Falha ao reindexar <grupo> <id>: ...')` | `ReindexPublicSearch.php:65,85,105,129` |
| Rollback da migration | `Log::alert("SEARCH ROLLBACK: Tabela '<t>' preservada como 'backup_<t>_<data>'.")` | migration `:111-113` |
| Migration de overlay | `Log::info('[FixSearchOverlayInputName] ...')` | migration `:57-60,86-88` |
| `publicSearch` com tabela ausente | exceção não capturada → 500 | `SearchService.php:26` |
| Sem resultados | `200` + estado vazio (“Nenhum resultado encontrado” / “Nenhum registro ou link administrativo encontrado para este termo.”) | views `:20-28` e `:10-14` |

Não há métrica, contador, auditoria nem health-check do índice. O `DashboardStatsService` fornece apenas a contagem de linhas.

## 9. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| `SearchController` | `SearchService::publicSearch/cmsSearch` | injeção no construtor | remover o serviço derruba `/busca` e `/cms/search` |
| 8 models (`News`, `Evento`, `AssociadoInstituicao`, `UnidadeRegional`, `Parceiro`, `JobOpening`, `BasicPage`, `BoardMember`) | DTOs + indexers | hooks `saved`/`deleted` | renomear DTO/campos quebra todos; `PublicIndexerService::remove` sem `try/catch` pode estourar no `delete` |
| `ContentRichEditorController@searchRecords` | leitura direta de `v2_public_search_index` | `title`/`search_deep_content` `ILIKE`, `limit(10)` | mudar colunas quebra o seletor de links (`ContentRichEditorController.php:65-85`) |
| `iframe-summary.blade.php` (CRE) | linha por `entity_id` + `LOWER(module)` | render de iframe de link interno | `module` com caixa diferente ainda casa (usa `LOWER`), mas `entity_type` não é considerado |
| `SEOService::enrichRankingWithSearchIndex` | `slug`, `module`, `title` | `whereIn('slug', ...)` e `keyBy('slug')` | slugs repetidos entre módulos sobrescrevem o rótulo do ranking |
| `DashboardStatsService::getContentStats` | `count()` da tabela | card “Páginas Indexadas” | inclui órfãos; sem flag de módulo |
| `v2:reindex-public` | `syncToSearch()` dos models | console | depende de todos os models existirem; `class_exists` só é checado para BasicPage |

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-SEARCH-01 | Não existe rotina de reconciliação/limpeza de órfãos no índice público nem no CMS | linhas apontam para conteúdo excluído; contagem do dashboard inflada | nenhum comando ou job em `V2/Services/Search/`; procurar cron em `app/Console/Kernel.php` (não há `schedule`) |
| LG-SEARCH-02 | Não há teste automatizado da busca (nenhum arquivo em `tests/`) | impossível verificar ranking/paginação sem banco real | `sucesu-cms/tests/` |
| LG-SEARCH-03 | O comportamento multi-tenant da busca não é especificado em nenhum doc | dúvida se o portal regional deve esconder conteúdo de outras regionais | `01_arquitetura/06-tenancy-regional.md` (escopo de listagens, não de busca) |
| LG-SEARCH-04 | `[L]` o dicionário `'portuguese'` depende da build do PostgreSQL (`pg_config`); não há verificação de `unaccent`/`pg_trgm` | stemming e acentuação podem variar entre ambientes | migrations de Search (não criam extensões); `docker-compose`/infra fora deste corpus |
| LG-SEARCH-05 | Nenhum consumidor lê `tags` ou `metadata` | semântica dessas colunas é apenas histórica | grep por `metadata`/`tags` sobre as duas tabelas |

## 11. Checklist de paridade

- [ ] `PC-SEARCH-01` — `GET /busca?q=associados` retorna `200` com cards; cada card tem badge do `module`, título, trecho e link clicável. Fonte: `SearchController.php:30-39`.
- [ ] `PC-SEARCH-02` — `GET /busca` sem `q` retorna `200` e “Nenhum resultado encontrado”. Fonte: `SearchService.php:22-24`.
- [ ] `PC-SEARCH-03` — salvar uma notícia publicada cria **uma** linha em `v2_public_search_index` (`entity_type = FQCN de News`) e uma em `v2_cms_links_index` (`module='noticias'`); salvar de novo atualiza, sem duplicar. Fonte: `NewsSearchSender.php:29-64`.
- [ ] `PC-SEARCH-04` — mudar a notícia para rascunho remove a linha pública e mantém a do CMS. Fonte: `NewsSearchSender.php:29-40`.
- [ ] `PC-SEARCH-05` — `search_vector` é recalculado em todo `INSERT`/`UPDATE` e contém lexemas de `title`, `summary_fragment` e `search_deep_content`, com pesos A/B/C. Fonte: migration `:63-79`.
- [ ] `PC-SEARCH-06` — buscar o acrônimo de uma regional encontra notícias e associados daquela regional (via `search_deep_content`). Fonte: `NewsSearchSender.php:86-104`; `AssociadoInstituicao.php:277-297`.
- [ ] `PC-SEARCH-07` — `GET /cms/search?q=noticias` com AJAX devolve envelope `{ok:true,data:{term,results}}` com ≤10 itens. Fonte: `SearchController.php:49-54`; `ApiResponse.php:10-18`.
- [ ] `PC-SEARCH-08` — os 10 primeiros itens da busca CMS são menus (quando casam); registros só aparecem se sobrar espaço. Fonte: `SearchService.php:232-234`.
- [ ] `PC-SEARCH-09` — `v2:reindex-public --only=news` imprime `Notícias reindexadas: N` e não toca em outras entidades. Fonte: `ReindexPublicSearch.php:31-33,53-71`.
- [ ] `PC-SEARCH-10` — `php artisan migrate` e depois `rollback` renomeia as duas tabelas para `backup_*` (não dropa). Fonte: migration `:98-123`.

## 12. Evidências consultadas

| Arquivo | Faixas |
|---|---|
| `V2/Services/Search/Services/SearchService.php` | 1-236 |
| `V2/Services/Search/Services/PublicIndexerService.php` | 1-73 |
| `V2/Services/Search/Services/CMSIndexerService.php` | 1-66 |
| `V2/Services/Search/DTOs/PublicSearchDTO.php` | 1-60 |
| `V2/Services/Search/DTOs/CMSIndexDTO.php` | 1-48 |
| `V2/Services/Search/Console/Commands/ReindexPublicSearch.php` | 1-136 |
| `V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php` | 1-124 |
| `V2/Services/Search/Migrations/2026_07_24_000001_fix_search_overlay_input_name.php` | 1-92 |
| `V2/Services/Search/README.md` | 1-52 |
| `V2/Services/README.md` | 1-37 |
| `V2/Core/Controllers/SearchController.php` | 1-61 |
| `V2/Core/Traits/ApiResponse.php` | 1-35 |
| `V2/Core/Routes/web.php` / `V2/Core/Routes/cms.php` | 1-30 / 1-40 |
| `V2/Core/Providers/V2ServiceProvider.php` | 30-90, 133-240, 256-285 |
| `V2/Core/Helpers/helpers.php` | 54-71 (`v2_uuid`) |
| `V2/layouts/portal/search/index.blade.php`, `V2/layouts/portal/header/header-search-area.blade.php`, `V2/layouts/cms/search/index.blade.php`, `V2/layouts/cms/layouts/app.blade.php:107-140` | — |
| `public/assets/js/cms-layout.js` | 25-175 |
| Modelos: `News.php:285-400`, `Evento.php:95-245`, `AssociadoInstituicao.php:195-360`, `UnidadeRegional.php:120-200`, `BoardMember.php:80-262`, `Parceiro.php:70-170`, `JobOpening.php:120-230`, `BasicPage.php:130-260` | — |
| `V2/Modules/Publicacoes/Noticias/Services/NewsSearchSender.php` | 1-135 |
| `V2/Modules/*/*/Routes/web.php` (URIs públicas) | — |

### 4.8 SVC-INDEX — contrato do README de `V2/Services/`

O README de `V2/Services/` é a única documentação do **formato de pacote** de serviço. O que ele fixa, e que o código confirma:

| Afirmação do README | Confirmação no código |
|---|---|
| Um serviço pode entregar lógica, views e migrations próprias (`V2/Services/README.md:5-20`) | O provider carrega `Migrations/` e `Views/` de todo diretório de `V2/Services/*` e publica views em `v2-service-<slug>` (`V2/Core/Providers/V2ServiceProvider.php:176-185`) |
| Consumo backend por `app(\V2\Services\X\XService::class)` (`:24-28`) | É o padrão de todos os serviços, incl. Search/Dashboard |
| Macro-componentes via `@component('v2-service-<slug>::...')` (`:30-34`) | `v2-service-gallery::components.gallery-picker` em `V2/layouts/cms/layouts/app.blade.php:139` |
| Estrutura `MeuServico/{MeuServico.php, Migrations/, Views/{cms,portal}}` (`:13-20`) | Search foge do padrão: sem `Views/`, sem classe homônima, lógica em `Services/`, DTOs em `DTOs/`, comando em `Console/Commands/` |
| Serviços exigem rota própria em `Routes/{web,cms,api}.php` com prefixo `api/v2/<slug>` (`V2/Core/Providers/V2ServiceProvider.php:202-231`) | Search não tem `Routes/`: usa rotas do Core |

**Leitura correta para a reimplementação:** o README é um guia de intenção, não um contrato verificado — `V2/Services/Search/` é a exceção mais visível (README próprio declara “sem observers”, enquanto o código usa `saved` em 8 models). Não copie a estrutura de pastas do README como regra; verifique cada serviço contra o provider.
