---
id: MOD-CONEXOES-PARCEIROSINSTITUCIONAIS
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-CONEXOES-PARCEIROSINSTITUCIONAIS]
fontes:
  - V2/Modules/Conexoes/ParceirosInstitucionais/
depende_de: [CORE-BOOT, CORE-AUTH, CORE-DATA, CORE-CMS, SVC-STORAGE, SVC-MEDIA, SVC-CONTENTRICHEDITOR, SVC-SEO, SVC-SEARCH, SVC-ACTIVITYLOGGER]
consumidores: [MOD-LAYOUTS-HOMEPAGE, UI-PORTAL, UI-CMS, CORE-CMS, SVC-SEARCH, SVC-SEO, SVC-DASHBOARD, DATA-SEEDS-CONTENT, DATA-SEEDS-BASICPAGES]
criticidade: media
dono: ParceirosInstitucionais
---

# Conexões/ParceirosInstitucionais

> Cadastro único de empresas parceiras da SUCESU: uma linha em
> `v2_parceiros_institucionais` alimenta, ao mesmo tempo, a página pública
> `/parceiros-institucionais`, o modal de detalhe por AJAX, o widget de carrossel da
> Homepage (`sucesu-associados`), o índice de busca pública e o sitemap. O módulo é um
> CRUD CMS de 9 endpoints + 2 rotas públicas, sem categoria/tipo e sem destaque: a
> segmentação é feita por **tags** (`v2_conexao_tags`) e a visibilidade por `is_active`
> + `sort_order`. Anexos: [`dados.md`](dados.md), [`rotas.md`](rotas.md),
> [`cms.md`](cms.md), [`portal.md`](portal.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Insere/atualiza uma linha em `v2_parceiros_institucionais`, gera slug único, sincroniza tags e redes sociais, reindexa a busca pública e invalida o page cache do portal. |
| Entrada | CMS: `GET/POST /cms/parceiros-institucionais[/novo|/editar/{id}|/preview/{id}|/{id}/toggle|/reorder]` (sessão Sentinel). Portal: `GET /parceiros-institucionais` e `GET /parceiros-institucionais/detalhe/{id}`. Homepage: widget `sucesu-associados` e seção `dynamic-partners`. |
| Saída | HTML CMS (index/create/edit/preview), fragmento HTML do modal, JSON de toggle/reorder, linha em `v2_parceiros_institucionais`, linhas em `v2_parceiro_tag_pivot`, linha em `v2_public_search_index`, URL no sitemap. |
| Efeitos colaterais | `activity_log` (`created`/`updated`/`archived`), flush de `laravel:page:*` no Redis, upsert/remoção no índice público, resposta HTTP cacheável por 60 s (`public.cache`) e page cache de 5 min. |
| Particularidade central | O "tipo" do parceiro não existe como coluna: categorização vem do pivô de tags; o slug é derivado no hook `saving` sem coluna editável; redes sociais vivem em duas fontes simultâneas (`linkedin_url/instagram_url/facebook_url` + `metadata.social_media`). |
| Como se integra | Namespace `V2\Modules\Conexoes\ParceirosInstitucionais`; views `v2-parceirosinstitucionais::cms.*` e `::portal.*`; model `Parceiro`; rotas nomeadas `v2.parceiros-institucionais.*`; permissão RBAC com chave `ParceirosInstitucionais`. |
| Se removido, quebra | Homepage (`SucesuAssociadosWidget`, `HomepageDataIntegrator::fetchPartners`, `HomepageSectionResolver::resolvePartners`), sitemap (`SeoPortalController::addPartners`), busca pública (módulo `parceiros-institucionais`), dashboard (contador), `PopulateMissingImages`, `DemoContentSeeder`, modal de tags (`ConexaoTag::parceiros()`). |
| Superfície pública | `Parceiro` (model + scopes `active`/`ordered` + `syncTagsFromArray`/`getHtmlDescription`/`syncToSearch`), `ParceirosInstitucionaisCmsController`, `ParceirosInstitucionaisPortalController`, `ModuleProvider::getConfig()`, `SucesuAssociadosWidget`. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

| Perfil | Ação | Resultado observável |
|---|---|---|
| Visitante do portal | Abrir `/parceiros-institucionais` | Grade de cards (2 colunas) dos parceiros **ativos**, ordenados por `sort_order` e `name`, 12 por página, com logo, resumo e ícones sociais; cada card abre um modal carregado por AJAX. |
| Visitante do portal | Abrir o modal de um card | `GET /parceiros-institucionais/detalhe/{id}` devolve **somente o corpo do modal** (sem layout), com logo 180×180, endereço, link do Maps, descrição rica e redes sociais. |
| Visitante da Homepage | Ver o carrossel "Parceiros Institucionais" | Swiper com logos dos ativos (mínimo visual de 6 slides por repetição), cada slide abre o mesmo fragmento de detalhe via `data-url`. |
| Operador com permissão `write` | Criar parceiro em `/cms/parceiros-institucionais/novo` | Linha criada com `sort_order = max+1`, `is_active = true`, `created_by` = id do usuário Sentinel, slug único e tags sincronizadas; redireciona ao índice com modal `created_item`. |
| Operador com permissão `write` | Editar em `/editar/{id}` | Campos sobrescritos; `logo_id` vazio é convertido em `null`; flash `Parceiro atualizado com sucesso!`; permanece na tela de edição com o preview atualizado. |
| Operador com permissão `write` | Clicar no toggle de ativo | `POST /{id}/toggle` devolve JSON e a lista recarrega; parceiro desativado vai para `sort_order = -1` e os ativos seguintes são decrementados. |
| Operador com permissão `write` | Arquivar (ícone `bi-archive`) | Efeito do endpoint: soft delete (`deleted_at`), remoção do índice e flash `Parceiro "X" arquivado com sucesso!`; na tela, porém, o botão não tem handler JS (ver §3.5). |
| Operador com permissão `read` | Abrir listagem/edição/preview | Lê normalmente; qualquer POST/PUT/DELETE é redirecionado com erro de permissão de leitura. |

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| `INSERT`/`UPDATE` em `v2_parceiros_institucionais` (id UUID v4 gerado na aplicação quando vazio) | `Parceiro::creating` + `$parceiro->save()` | `V2/Modules/Conexoes/ParceirosInstitucionais/Models/Parceiro.php:54-58`; `Controllers/ParceirosInstitucionaisCmsController.php:70-97` |
| Slug único derivado de `name` no primeiro save; em updates o slug existente é preservado (sufixo `-1`, `-2`… em colisão) | hook `saving` | `Models/Parceiro.php:60-77` |
| `metadata.social_media` regravado como lista filtrada `network`+`url` (preserva `icon`, `order`, `active` vindos do cliente) | `parseSocialJson` + `update` | `Controllers/ParceirosInstitucionaisCmsController.php:84-90,157-162,294-306` |
| Criação/reuso de tags por slug e `sync` do pivô `v2_parceiro_tag_pivot` com `is_primary` na primeira | `Parceiro::syncTagsFromArray` | `Models/Parceiro.php:108-124` |
| Upsert em `v2_public_search_index` (`entity_type`, `entity_id` como chave de conflito) ou remoção quando `is_active = false` | `Parceiro::syncToSearch` no hook `saved` | `Models/Parceiro.php:79-85,140-163` |
| Remoção do índice público no soft delete | hook `deleted` | `Models/Parceiro.php:87-93` |
| Remoção de todas as chaves Redis `laravel:page:*` | `clearPageCache()` | `Controllers/ParceirosInstitucionaisCmsController.php:274-286` |
| Linha em `activity_log` (`created`/`updated`/`archived` + `v2_audit`, IP, user agent) | `ActivityLoggerService::log` | `Controllers/ParceirosInstitucionaisCmsController.php:101-105,174-178,193-196,238-241` |
| Flash de criação (`created_item`) e de edição/arquivamento | controller | `Controllers/ParceirosInstitucionaisCmsController.php:107-111,180,198` |
| Cabeçalhos `Cache-Control: public, max-age=60, must-revalidate` + `Vary: Accept-Encoding` nas rotas públicas | `PublicCacheMiddleware` | `V2/Core/Middleware/PublicCacheMiddleware.php:80-92` |
| Entrada no sitemap com `priority 0.4` (detalhe) e `0.5` (índice) | `SeoPortalController::addPartners` | `V2/Services/SEO/Controllers/SeoPortalController.php:248-283` |

### 1.3 Contrato observável (entrada → saída)

| Entrada válida | Resultado |
|---|---|
| `POST /cms/parceiros-institucionais/novo` com `name` (≤255) | `302` → `v2.parceiros-institucionais.cms.index`; linha criada; `created_item` na sessão. |
| `PUT /editar/{id}` com `name` válido | `302` → `v2.parceiros-institucionais.cms.edit`; flash success `Parceiro atualizado com sucesso!`. |
| `DELETE /{id}` | `302` → índice; flash `Parceiro "NOME" arquivado com sucesso!`. |
| `POST /{id}/toggle` | `200` JSON `{"success":true,"is_active":bool,"message":"Parceiro \"NOME\" ativado|desativado com sucesso!"}`. |
| `POST /reorder` com `ids[]` | `200` JSON `{"success":true}`; `sort_order` = posição 1..N. |
| `GET /parceiros-institucionais` | `200` HTML; 12 ativos por página; vazio → bloco "Nenhum parceiro cadastrado" / "Em breve divulgaremos nossos parceiros." |
| `GET /parceiros-institucionais/detalhe/{id}` | `200` fragmento HTML; `id` inexistente → `404` de `findOrFail`. |

| Entrada inválida | Erro exato |
|---|---|
| `name` ausente ou >255 | `302` de volta com `The name field is required.` / `The name may not be greater than 255 characters.` (locale `pt-br` sem arquivos de tradução → fallback `en`). |
| `summary` >250 | `The summary may not be greater than 250 characters.` |
| `google_maps_url` >500 | `The google maps url may not be greater than 500 characters.` |
| `tags` não-array | `The tags must be an array.` |
| `logo_id` >36 | `The logo id may not be greater than 36 characters.` |
| Sessão sem papel administrativo | `302` → login com `Acesso negado. Seu usuário não possui permissões administrativas.` |
| Permissão `none` no módulo | `302` → dashboard com `Acesso negado. Seu perfil não possui permissão para acessar o módulo: Parceiros Institucionais`. |
| Módulo desativado (`v2_module_flags`) | AJAX: `404` JSON `{"error":"Modulo desativado: ParceirosInstitucionais","code":404}`; navegação: `404 Modulo desativado.` (HTML, sem redirect ao dashboard). |

### 1.4 O que este módulo NÃO faz

- **Não** tem categoria, tipo, segmento ou nível de parceria: só tags livres (`v2_conexao_tags`).
- **Não** tem flag de "destaque": o único eixo de visibilidade é `is_active`; a ordem é automática, sem campo no formulário.
- **Não** expõe listagem pública filtrável: sem query de tag, sem busca, sem ordenação por query string.
- **Não** apaga fisicamente (`SoftDeletes`); não limpa o pivô de tags no archive.
- **Não** redimensiona/gera variantes do logo no módulo: delega ao `SVC-MEDIA`/`SVC-GALLERY` (`logo_id` → `GalleryMedia`).
- **Não** grava SEO por parceiro: `metadata.seo` só é escrito por seeder e não é lido por nenhuma view.
- **Não** invalida o cache HTTP/CDN de 60 s ao salvar; apenas o page cache Redis.

## 2. Abordagem

### 2.1 Estratégia em uma página

O módulo é um CRUD "magro" com um model que concentra três automações: identidade (UUID + slug), taxonomia (tags por nome) e descoberta (índice de busca). O controller não usa FormRequest nem Service: valida inline, monta o model campo a campo, salva, sincroniza tags e dispara efeitos de cache/auditoria. O portal lê o mesmo model por dois caminhos: página completa (index) e fragmento para modal (detalhe). A Homepage lê por dois caminhos legados (integrador de seções e resolver) e um atual (widget LSE).

```mermaid
flowchart LR
  A[Form CMS] --> B[validate inline]
  B --> C[Parceiro save]
  C --> D[hook creating: UUID]
  C --> E[hook saving: slug único]
  C --> F[hook saved: syncToSearch]
  B --> G[syncTagsFromArray]
  B --> H[parseSocialJson -> metadata]
  C --> I[clearPageCache Redis]
  C --> J[activity_log]
  K[Portal index] --> L[Parceiro active ordered paginate 12]
  M[Modal AJAX detalhe] --> N[findOrFail + toHtml]
  O[Homepage widget] --> P[active ordered hidden_slugs]
```

### 2.2 Mecânica passo a passo

1. `CmsAuthenticate` valida `Sentinel::check()`, exige ≥1 papel e delega a `V2PermissionMiddleware` (`V2/Core/Middleware/CmsAuthenticate.php:20-44`).
2. `V2PermissionMiddleware` casa `active_pattern = ['cms/parceiros-institucionais*']` e resolve a chave de permissão como **nome do diretório** `ParceirosInstitucionais`; `super-admin` e o e-mail de suporte passam direto (`V2/Core/Middleware/V2PermissionMiddleware.php:40-72`).
3. O provider de boot envolve as rotas CMS em `web` + `module.active:ParceirosInstitucionais` (flag padrão ativa quando ausente) (`V2/Core/Providers/V2ServiceProvider.php:303-307`).
4. `store()` valida 11 campos (`name` obrigatório; `tags.*` string ≤100) e instancia `new Parceiro()` (`Controllers/ParceirosInstitucionaisCmsController.php:54-75`).
5. Atribui campos, `sort_order = Parceiro::max('sort_order') + 1`, `is_active = true`, `metadata.social_media` e `created_by` (`:76-91`).
6. `save()` dispara `creating` (UUID se vazio) e `saving` (slug derivado do nome apenas quando vazio; desambiguação por contador) (`Models/Parceiro.php:54-77`); depois `saved` → `syncToSearch()` (`:79-85`).
7. `syncTagsFromArray()` normaliza nomes, faz `firstOrCreate` por slug e `sync()` do pivô com `is_primary` na posição 0 (`:108-124`).
8. `clearPageCache()` apaga `laravel:page:*` no Redis, tolerando falha com `Log::warning` (`Controllers/ParceirosInstitucionaisCmsController.php:274-286`).
9. `ActivityLoggerService::log($parceiro,'created',['type'=>'parceiro_create','name','author'])` grava auditoria com IP e user agent (`V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`).
10. Sessão recebe `created_item` e o redirect leva ao índice, onde o partial `creation-success-modal` exibe o modal de sucesso (`Controllers/ParceirosInstitucionaisCmsController.php:107-113`).
11. `update()` repete validação/atribuição; `logo_id` vazio limpa a coluna (`:157-166`).
12. `toggleActive()` calcula ordem e devolve JSON; `destroy()` faz soft delete e o hook `deleted` remove do índice (`:216-250,185-200`; `Models/Parceiro.php:87-93`).

### 2.3 Decisões de design e por quê

- **Slug no model, não no controller** `[C]`: qualquer escrita (seeder, importação, tinker) ganha slug se ele estiver vazio; o `$fillable` nem inclui `slug` (`Models/Parceiro.php:22-37,60-77`). Renomear a entidade não troca o slug, o que preserva URLs de busca/blacklist.
- **Tags como texto no formulário** `[C]`: hidden inputs `tags[]` são montados por JS; o servidor aceita strings e faz `firstOrCreate` — não há endpoint de autocomplete (`Views/cms/_form.blade.php:180-197`; `Models/Parceiro.php:113-121`).
- **Redes sociais em `metadata` JSONB** `[C]`: permite redes além das três colunas legadas (`twitter`, `youtube`, `whatsapp`, `telegram`, `tiktok`) sem migration (`Views/cms/_form.blade.php:282-291`).
- **Duas fontes de redes sociais** `[C]`: o form mantém hidden inputs `linkedin_url`/`instagram_url`/`facebook_url` "para compatibilidade com o controller" e sincroniza a partir do builder (`_form.blade.php:130-134,300-313`).
- **Efeitos best-effort** `[C]`: busca, cache e render de descrição falham com `Log` e não interrompem a escrita (`Models/Parceiro.php:79-93,126-163`).
- **Ordenação automática** `[C]`: não há campo `sort_order` no form; o valor nasce no fim da fila e só muda por toggle/reorder (`Controllers/...CmsController.php:82,216-235`).
- **Paginação pública fixa em 12** `[C]`: sem override por query (`Controllers/ParceirosInstitucionaisPortalController.php:26-28`).

### 2.4 Trechos de código pivô

```php
// V2/Modules/Conexoes/ParceirosInstitucionais/Models/Parceiro.php:60-77 — slug único no save
static::saving(function ($model) {
    $baseSlug = $model->slug ?: str_slug($model->name);
    $slug = $baseSlug;
    $counter = 1;
    if (!empty($model->id)) {
        while (static::where('slug', $slug)->where('id', '!=', $model->id)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
    } else {
        while (static::where('slug', $slug)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
    }
    $model->slug = $slug;
});
```

O slug não é editável na UI: o formulário apenas o exibe em cinza (`Views/cms/_form.blade.php:50-57`). O laço é O(n) em colisões e ignora registros soft-deleted (o `SoftDeletes` global scope os esconde), embora o índice `unique(slug)` os inclua — ver RN-06.

```php
// V2/Modules/Conexoes/ParceirosInstitucionais/Models/Parceiro.php:140-159 — indexação
// pública: module fixo, tags fixas, summary = description crua.
public function syncToSearch()
{
    try {
        if (!$this->is_active) {
            app(PublicIndexerService::class)->remove(get_class($this), $this->id);
            return;
        }
        $publicDto = new PublicSearchDTO(
            $this->name,
            $this->slug,
            'parceiros-institucionais',
            get_class($this),
            $this->id,
            // ... summary = description, tags fixas, deep content sem HTML
        );
        app(PublicIndexerService::class)->index($publicDto);
    } catch (\Exception $e) {
        \Log::error('[Parceiro] Falha no syncToSearch: ' . $e->getMessage());
    }
}
```

O índice usa `ON CONFLICT (entity_type, entity_id)` (`V2/Services/Search/Services/PublicIndexerService.php:21-61`), então saves repetidos não duplicam. A `summary` indexada é o JSON do editor rico (não a coluna `summary`), e as tags são **hardcoded**, não as tags do pivô — a busca pública não segmenta parceiros por tag.

```php
// V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisCmsController.php:82-99
// criação: ordem no fim da fila, metadata social e logo opcional
$parceiro->sort_order = Parceiro::max('sort_order') + 1;
$parceiro->is_active = true;

$metadata = $parceiro->metadata ?? [];
$metadata['social_media'] = $this->parseSocialJson($request->input('social_media_json'));
$parceiro->metadata = $metadata;
$parceiro->created_by = Sentinel::getUser()->id ?? null;
if ($request->has('logo_id') && $request->input('logo_id') !== '') {
    $parceiro->logo_id = $request->input('logo_id');
}

$parceiro->save();
$parceiro->syncTagsFromArray($request->input('tags', []));
```

`Parceiro::max` exclui soft-deleted: com todos arquivados o cálculo parte de `null + 1 = 1`, reutilizando ordens. `created_by` não está em `$fillable`, mas é atribuído direto (não há mass assignment).

```php
// V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisCmsController.php:216-235
// toggle: desativar empurra para -1 e fecha o buraco na fila dos ativos
if ($wasActive) {
    $oldOrder = $parceiro->sort_order;
    $parceiro->is_active = false;
    $parceiro->sort_order = -1;
    $parceiro->save();

    Parceiro::where('is_active', true)
        ->where('sort_order', '>', $oldOrder)
        ->decrement('sort_order');
} else {
    $maxOrder = Parceiro::where('is_active', true)->max('sort_order');
    $parceiro->is_active = true;
    $parceiro->sort_order = ($maxOrder ?? -1) + 1;
    $parceiro->save();
}
```

O `save()` de desativação dispara `syncToSearch()` e remove a entrada; o de reativação a recria. O `decrement` usa query builder e **não** dispara hooks — os demais parceiros não são reindexados (desnecessário, mas vale saber).

```php
// V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisCmsController.php:274-286
// invalidação de página: varredura por padrão no Redis, falha silenciosa
try {
    $redis = Redis::connection();
    $pattern = config('cache.prefix', 'laravel') . ':page:*';
    $keys = $redis->keys($pattern);
    if (!empty($keys)) {
        $redis->del($keys);
    }
} catch (\Exception $e) {
    Log::warning('[ParceirosInstitucionaisCmsController] Falha ao limpar cache de páginas: ' . $e->getMessage());
}
```

`KEYS` é O(N) e bloqueante; com Redis indisponível a escrita continua e o portal pode servir até 5 min de conteúdo antigo (TTL do `PageCacheMiddleware`). Não há invalidação por chave específica — qualquer alteração de parceiro limpa **todo** o page cache do site.

### 2.5 Algoritmos, consultas e custos

| Consulta | Onde | Custo/observação |
|---|---|---|
| `Parceiro::query()->where(name ILIKE ...)->orWhere(description ILIKE ...)` + `orderBy('sort_order')->orderBy('created_at','desc')->paginate(15)` | `Controllers/...CmsController.php:32-47` | `ILIKE '%termo%'` sem índice trigram: full scan; `search` vazio não filtra. |
| `Parceiro::with('galleryLogo')->active()->ordered()->paginate(12)` | `Controllers/ParceirosInstitucionaisPortalController.php:26` | 2 queries (count + page) + 1 eager load; índices `idx_..._active` e `idx_..._sort`. |
| `findOrFail($id)` + `relationLoaded('tags')`/`galleryLogo` | `edit`/`detalhe`/`preview` | PK lookup; `belongsTo` por `logo_id`. |
| `Parceiro::max('sort_order')`, `where(...)->max(...)`, `decrement` | store/toggle | 1–2 queries por operação. |
| `ConexaoTag::orderBy('name')->get()` a cada render de `_form` | `Views/cms/_form.blade.php:3` | Lista completa de tags em toda tela create/edit. |
| `syncTagsFromArray`: 1 `SELECT`+possível `INSERT` por tag + `sync` | `Models/Parceiro.php:113-123` | N+1 proporcional ao número de tags digitadas; corrida em `firstOrCreate` sob slug único. |
| `Parceiro::whereNull('logo_id')->get()` | `PopulateMissingImages` | Lote sem paginação. |

## 3. Particularidades

### 3.1 O que só existe aqui

- **Slug calculado como identidade colateral**: nenhum outro ponto do cadastro de conexões documenta slug derivado de nome com desambiguação por contador no hook `saving`; o portal, porém, detalha por `id`, então o slug só importa para busca, widget (`hidden_slugs`) e sitemap conceitual.
- **Duas representações de redes sociais**: colunas `linkedin_url`/`instagram_url`/`facebook_url` (legado, escritas pelos hidden inputs) e `metadata.social_media` (novo, lido por portal/home). O portal lê **apenas** `metadata`.
- **`summary` em texto puro com fallback rico**: a listagem usa `summary ?: str_limit(strip_tags(getHtmlDescription()), 250)` (`Views/portal/index.blade.php:74`).
- **Blacklist de Homepage**: o widget `sucesu-associados` mostra todos os ativos e usa `hidden_slugs` para ocultar — o oposto de uma whitelist (`V2/Modules/Layouts/Homepage/LseWidgets/Widgets/SucesuAssociadosWidget.php:28-41`).
- **Repetição para preencher o carrossel**: se houver menos de 6 itens, a lista é duplicada até 6 (`SucesuAssociadosWidget.php:53-59`).

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-PARCEIROSINSTITUCIONAIS-01 | Todo parceiro nasce ativo e no fim da fila (`max(sort_order)+1`); não há campo de ordem no form. | Ordem só muda por toggle/reorder. | `Controllers/ParceirosInstitucionaisCmsController.php:82-83`; `Views/cms/_form.blade.php:28-167` |
| RN-PARCEIROSINSTITUCIONAIS-02 | Desativar grava `sort_order = -1` e decrementa os ativos com ordem maior. | Desativados aparecem **no topo** da listagem CMS (ordena por `sort_order`), embora sumam do portal. | `Controllers/...CmsController.php:223-230`; `:44` |
| RN-PARCEIROSINSTITUCIONAIS-03 | Reativar recoloca no fim (`max(ativos)+1`). | Não recupera a posição original. | `Controllers/...CmsController.php:232-235` |
| RN-PARCEIROSINSTITUCIONAIS-04 | Slug é único e calculado **só quando vazio**: `$model->slug ?: str_slug($model->name)`. | Renomear o parceiro **não** altera o slug; a URL de busca/widget permanece estável após o create. | `Models/Parceiro.php:60-77`; migration `:33` |
| RN-PARCEIROSINSTITUCIONAIS-05 | `metadata.social_media` só mantém itens com `network` **e** `url` não vazios. | Linha incompleta é descartada silenciosamente. | `Controllers/...CmsController.php:294-306` |
| RN-PARCEIROSINSTITUCIONAIS-06 | Unicidade de slug considera soft-deleted (constraint), mas o laço do model não. | Reutilizar o nome de um parceiro arquivado gera `23505` (unique violation) no `INSERT`. | `Models/Parceiro.php:66`; `Migrations/2024_01_01_000002_create_v2_parceiros_table.php:33` |
| RN-PARCEIROSINSTITUCIONAIS-07 | `syncToSearch` grava `module = parceiros-institucionais`, tags fixas e `summary = description`. | Busca não usa `summary` nem tags reais; URL do resultado exige `entity_id`. | `Models/Parceiro.php:148-159`; `V2/Services/Search/Services/SearchService.php:83-84` |
| RN-PARCEIROSINSTITUCIONAIS-08 | Inativo = removido do índice público; arquivado = removido também. | Busca nunca devolve inativo, mas o sitemap **inclui** inativos (filtra só `deleted_at`). | `Models/Parceiro.php:143-145,87-93`; `V2/Services/SEO/Controllers/SeoPortalController.php:258-266` |
| RN-PARCEIROSINSTITUCIONAIS-09 | O detalhe público não aplica `active()`. | `GET /detalhe/{id}` de parceiro desativado retorna 200 com o fragmento. | `Controllers/ParceirosInstitucionaisPortalController.php:38-50` |
| RN-PARCEIROSINSTITUCIONAIS-10 | `logo_id` vazio no update limpa o logo; no store, vazio simplesmente não atribui. | Não é possível "manter" o logo se o campo não vier no request (o `media-input` sempre envia hidden). | `Controllers/...CmsController.php:90-92,164-168` |
| RN-PARCEIROSINSTITUCIONAIS-11 | Arquivar não remove tags nem reduz `sort_order`. | Pivô fica órfão (sem FK/cascade) e a fila mantém buraco. | `Controllers/...CmsController.php:185-200`; migration do pivô `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:26-35` |
| RN-PARCEIROSINSTITUCIONAIS-12 | O index CMS mostra ícone, nunca miniatura do logo. | A listagem só verifica `$item->logo_id` para escolher o ícone. | `Views/cms/index.blade.php:53-59` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Tabela vazia**: portal exibe "Nenhum parceiro cadastrado" / "Em breve divulgaremos nossos parceiros." (`Views/portal/index.blade.php:115-120`); CMS exibe "Nenhum parceiro encontrado" e CTA "Criar Primeiro Parceiro" (`Views/cms/index.blade.php:114-129`).
- **Busca só por espaços**: `$request->has('search') && !empty(...)` — `"0"` e `" "` são tratados como vazios; não há trim.
- **Parceiro sem logo**: card usa placeholder `bi-building` 185×185; modal usa 180×180; preview usa 160×160.
- **Descrição vazia**: `htmlContent` fica `''` e o bloco nem é renderizado.
- **Descrição rica inválida**: `getHtmlDescription()` cai em `e($description)` no model (`Models/Parceiro.php:131-137`), mas o **portal controller** chama o renderer sem try/catch (`Controllers/ParceirosInstitucionaisPortalController.php:41-45`) → exceção vira 500.
- **JS desabilitado no form**: `social_media_json` permanece no valor inicial (redes vazias) e é filtrado para `[]`; os hidden inputs mantêm os valores antigos → colunas legadas preservadas, `metadata.social_media` **apagado**. Os `input[name="tags[]"]` só existem em `#tag-hidden-inputs`, populado por JS: sem JS, `syncTagsFromArray([])` desvincula todas as tags.
- **Slug de parceiro arquivado**: ver RN-06 (erro 500 de constraint, não mensagem amigável).
- **Reordenar com ids inexistentes**: `update` afeta 0 linhas e retorna sucesso mesmo assim (`Controllers/...CmsController.php:203-214`).
- **Módulo desativado**: o CMS responde `404 Modulo desativado.` (sem redirect), mas as rotas públicas continuam atendendo (não têm `module.active`).

### 3.4 Armadilhas de reimplementação

1. **Não unificar `logo` e `logo_id` sem migração de dados**: a coluna `logo` (varchar 255, caminho de arquivo) continua na tabela e é lida pelo `preview` (`Controllers/...CmsController.php:262-264`) e pelo `HomepageDataIntegrator` (`:136-141`), mas nunca é escrita pelo CRUD atual.
2. **Ordem dos hooks**: `creating` (UUID) precisa vir antes de `saving`, e `saving` antes de `saved` (busca) — inverter produz slug vazio no índice.
3. **`sort_order = -1` não é "inativo"**: é apenas o marcador de desativado; um registro criado com `sort_order` negativo por outro caminho ficaria no topo do CMS.
4. **`parseSocialJson` preserva `icon` do cliente**: o `class="bi {{ $social['icon'] }}"` no portal aceita qualquer string enviada; não há whitelist de ícones no servidor.
5. **`clearPageCache` usa `Redis::connection()` e `config('cache.prefix')`**: se o cache store não for Redis ou o prefixo divergir, a invalidação não funciona e nenhum erro aparece para o usuário.
6. **Permissão é por nome de diretório**: renomear `ParceirosInstitucionais` invalida silenciosamente as permissões existentes e o `module.active`.

### 3.5 Divergências documentação × código e dívidas

- **Nome do arquivo de migration vs tabela**: `2024_01_01_000002_create_v2_parceiros_table.php` cria `v2_parceiros_institucionais`, não `v2_parceiros` (`Migrations/2024_01_01_000002_create_v2_parceiros_table.php:30`). A migration de rename (`2026_07_31`) é, por isso, no-op em instalações novas.
- **Comentário do rename diz "sem rollback para o nome antigo"**, mas `down()` existe e renomeia de volta (`Migrations/2026_07_31_000000_rename_v2_parceiros_to_v2_parceiros_institucionais.php:7-35`).
- **Cleanup declara "nunca DROP" no rollback, mas o `up()` faz `Schema::drop('v2_parceiros')`** quando ambas existem (`Migrations/2026_08_06_000000_cleanup_old_parceiros_table.php:20,42-44`).
- **`preview()` lê `$parceiro->logo->file_path`** (coluna `varchar`, tratada como objeto): com `logo` nulo, `$logoUrl` fica `null`; com `logo` preenchido, gera notice e `TypeError` em `StorageService::url(string)` (500). A view ignora `$logoUrl` e usa `galleryLogo`.
- **`HomepageDataIntegrator::fetchPartners` devolve `image_id => $parceiro->logo`** (coluna legada, normalmente nula) em vez de `logo_id` (`V2/Modules/Layouts/Homepage/Services/HomepageDataIntegrator.php:150-153`).
- **`HomepageSectionResolver::resolvePartners` não devolve `url`** (`V2/Modules/Layouts/Homepage/Services/HomepageSectionResolver.php:488-511`), enquanto o widget devolve — dois contratos de item diferentes para a mesma seção.
- **Seeder de página fixa com mesmo slug da rota**: `ParceirosInstitucionaisSeeder` grava `v2_basic_pages.slug = 'parceiros-institucionais'`, mas a rota do módulo tem precedência sobre o fallback `/{slug}`, tornando a página semeada inalcançável por URL (`V2/Seeds/BasicPages/ParceirosInstitucionaisSeeder.php:63-99`).
- **Classe do seeder não bate com o arquivo**: arquivo `ParceirosInstitucionaisSeeder.php`, classe `ParceirosSeeder` (`:9`), chamada por `database/seeds/DatabaseSeeder.php:94` — só resolve pelo `classmap` do Composer.
- **`SEOService` injetado no controller CMS nunca é usado** (`Controllers/...CmsController.php:23,29`).
- **Arquivar inerte na UI**: o botão `data-confirm-modal` depende do JS que só existe em `V2/Modules/Publicacoes/Noticias/Views/cms/includes/scripts.blade.php:159-190`; o partial genérico é markup (`Views/cms/index.blade.php:88-100`).
- **`created_by` é o id do usuário Sentinel, não necessariamente UUID**, apesar do comentário "UUID do usuário" na coluna (`Migrations/2024_01_01_000002_...php:39`; `Controllers/...CmsController.php:91`).

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `Parceiro` | model Eloquent | tabela `v2_parceiros_institucionais`; `SoftDeletes`; PK string | tabela migrada | CMS, portal, Homepage, SEO, busca, dashboard, seeders |
| `Parceiro::scopeActive` | scope | `active()` → `where is_active = true` | — | portal, Homepage, dashboard, `PopulateMissingImages` |
| `Parceiro::scopeOrdered` | scope | `ordered()` → `orderBy sort_order, name` | — | portal, Homepage |
| `Parceiro::galleryLogo` | relação | `belongsTo(GalleryMedia::class,'logo_id')` | `logo_id` válido em `v2_gallery_media` (sem FK) | views CMS/portal |
| `Parceiro::tags` | relação | `belongsToMany(ConexaoTag::class,'v2_parceiro_tag_pivot','parceiro_id','tag_id')->withPivot('is_primary')->withTimestamps()` | pivô migrado | `_form`, `ConexaoTagCmsController` |
| `Parceiro::syncTagsFromArray` | método | `(array $tagNames): void` | dentro de request/console com DB | controller |
| `Parceiro::getHtmlDescription` | método | `(): string` (HTML; fallback escapado) | — | portal index, busca |
| `Parceiro::syncToSearch` | método | `(): void` (nunca lança) | `v2_public_search_index` existente | hooks, chamada manual |
| `ModuleProvider::getConfig` | provider | ver §10 | — | `SidebarComposer`, `V2PermissionMiddleware`, `SearchService::cmsSearch` |
| `ParceirosInstitucionaisCmsController` | controller | 9 actions | sessão + permissão | rotas |
| `ParceirosInstitucionaisPortalController` | controller | `index()`, `detalhe($id)` | — | rotas web |
| `SucesuAssociadosWidget` | widget LSE | `prepare(array $parameters, Collection $anexos = null): array` | registrado no `WidgetRegistry` | Homepage |

### 4.2 Contrato de UI/JS

- CMS: namespace de views `v2-parceirosinstitucionais`; partial `_form` espera `parceiro` (null em create) e depende do `$errors` compartilhado; o botão de toggle usa jQuery + `route(...)` com placeholder `__ID__` (`Views/cms/index.blade.php:137-165`).
- Portal: o card é um `div` com `data-bs-toggle="modal"` e `data-id`; o modal `#parceiroModal` recebe HTML em `#parceiroModalBody` via `$.get` do endpoint `detalhe` (`Views/portal/index.blade.php:53-57,197-222`).
- Homepage: o slide é `<a data-bs-toggle="modal" data-bs-target="#parceiroHomeModal" data-url="...">`; o render usa `XMLHttpRequest` e injeta em `#parceiroHomeModalBody` (`V2/Modules/Layouts/Homepage/Views/lse/render/associados.blade.php:9-18,116-146`).
- O fragmento de detalhe **não** inclui `<html>`/layout; qualquer inclusão fora de modal herda o estilo do container chamador.

### 4.3 Ordem e ciclo de vida

1. `V2ServiceProvider` descobre `Modules/Conexoes/ParceirosInstitucionais`, instancia `ModuleProvider`, carrega migrations e views como `v2-parceirosinstitucionais`, e mapeia `Routes/web.php` e `Routes/cms.php` (`V2/Core/Providers/V2ServiceProvider.php:96-128,290-313`).
2. Rotas web recebem `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache`; rotas CMS recebem `web`, `module.active:ParceirosInstitucionais` (`:295-307`).
3. O fallback `/{slug}` é registrado depois — por isso `/parceiros-institucionais` do módulo vence a BasicPage de mesmo slug.
4. `SEOServiceProvider` injeta `seo*` nas views de metadata e chama `trackAccess()` a cada render (`V2/Services/SEO/Providers/SEOServiceProvider.php:19-51`).
5. Nenhum observer/evento além dos hooks do model; nenhum job assíncrono.

### 4.4 Receitas de uso

**Listar ativos para um widget próprio:**

```php
$parceiros = \V2\Modules\Conexoes\ParceirosInstitucionais\Models\Parceiro::with('galleryLogo')
    ->active()
    ->ordered()
    ->take(6)
    ->get();
// cada $parceiro->galleryLogo->file_path (ou null) e $parceiro->name
```

**Criar um parceiro por código (respeita hooks):**

```php
$p = new \V2\Modules\Conexoes\ParceirosInstitucionais\Models\Parceiro();
$p->name = 'ACME';
$p->summary = 'Resumo curto';
$p->is_active = true;
$p->sort_order = 10;
$p->metadata = ['social_media' => [['network' => 'linkedin', 'icon' => 'bi-linkedin', 'url' => 'https://...', 'active' => true]]];
$p->save();               // UUID + slug + índice público
$p->syncTagsFromArray(['Ouro']);
```

**Montar a URL pública de detalhe (a única forma canônica):**

```php
route('v2.parceiros-institucionais.portal.detalhe', ['id' => $parceiro->id]);
// /parceiros-institucionais/detalhe/{uuid} — nunca usar slug na URL pública
```

**Consumir o fragmento do modal por HTTP:**

```http
GET /parceiros-institucionais/detalhe/0192f0c4-... HTTP/1.1
X-Requested-With: XMLHttpRequest
```

→ `200 text/html` com bloco `<div class="text-center">…`; `404` se o id não existir.

### 4.5 Anti-uso

1. **Inserir via `DB::table('v2_parceiros_institucionais')->insert([...])` sem `id`**: a PK uuid não tem default; o insert falha. Use o model (UUID no `creating`) ou gere `v2_uuid()`.
2. **Esperar que `summary` alimente a busca pública**: `syncToSearch` usa `description`; alterar só `summary` não muda o snippet da busca.
3. **Filtrar o portal por tag via query string**: não existe; qualquer `?tag=` é ignorada e o cache de página pode devolver a listagem completa para a mesma URL base.

### 4.6 Custos e limites da integração

- Page cache: 5 min por URL (`V2/Core/Middleware/PageCacheMiddleware.php:11-14,88-91`); chave `page:sha1(fullUrl)`.
- Cache HTTP: 60 s (`public.cache`), revalidação obrigatória; não é limpo pelo controller.
- Busca: `ts_headline`/`ts_rank` em `v2_public_search_index`, limite 20 resultados (`V2/Services/Search/Services/SearchService.php:26-38`).
- Sitemap: uma linha por parceiro (inclusive inativo) + índice (`SeoPortalController.php:258-280`).
- Upload do logo: limites do `SVC-MEDIA`; o módulo aceita `logo_id` de até 36 caracteres e não valida existência.

## 5. Rotas e contratos HTTP

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|---|
| grupo | — | `cms/parceiros-institucionais` | — | `web`, `module.active:ParceirosInstitucionais`, `CmsAuthenticate`→`V2PermissionMiddleware` | — | `ParceirosInstitucionais` (`none`/`read`/`write`) | agrupa 9 endpoints do CMS | `Routes/cms.php:3-6`; `V2/Core/Providers/V2ServiceProvider.php:303-307` |
| endpoint | GET | `/cms/parceiros-institucionais` | `v2.parceiros-institucionais.cms.index` | idem | `...CmsController@index` | `read` basta | lista paginada (15) com busca `name`/`description` | `Routes/cms.php:8-11` |
| endpoint | GET | `/cms/parceiros-institucionais/novo` | `...cms.create` | idem | `@create` | `read` (form) | renderiza formulário vazio | `Routes/cms.php:13-16` |
| endpoint | POST | `/cms/parceiros-institucionais/novo` | `...cms.store` | idem + CSRF | `@store` | `write` | cria linha, tags, índice, limpa cache | `Routes/cms.php:18-21` |
| endpoint | GET | `/cms/parceiros-institucionais/editar/{id}` | `...cms.edit` | idem | `@edit` | `read` | form preenchido + `$logoUrl` | `Routes/cms.php:23-26` |
| endpoint | PUT/POST | `/cms/parceiros-institucionais/editar/{id}` | `...cms.update` | idem + CSRF | `@update` | `write` | atualiza, tags, índice, flash | `Routes/cms.php:28-31` |
| endpoint | DELETE | `/cms/parceiros-institucionais/{id}` | `...cms.destroy` | idem + CSRF | `@destroy` | `write` | soft delete + remoção do índice | `Routes/cms.php:33-36` |
| endpoint | POST | `/cms/parceiros-institucionais/reorder` | `...cms.reorder` | idem + CSRF | `@reorder` | `write` | reordena por `ids[]`; JSON | `Routes/cms.php:38-41` |
| endpoint | POST | `/cms/parceiros-institucionais/{id}/toggle` | `...cms.toggle` | idem + CSRF | `@toggleActive` | `write` | ativa/desativa; JSON | `Routes/cms.php:43-46` |
| endpoint | GET | `/cms/parceiros-institucionais/preview/{id}` | `...cms.preview` | idem | `@preview` | `read` | página de preview (iframe) | `Routes/cms.php:48-51` |
| endpoint | GET | `/parceiros-institucionais` | `v2.parceiros-institucionais.portal.index` | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | `PortalController@index` | pública | listagem paginada (12) | `Routes/web.php:7-10`; `V2ServiceProvider.php:295-298` |
| endpoint | GET | `/parceiros-institucionais/detalhe/{id}` | `v2.parceiros-institucionais.portal.detalhe` | idem | `@detalhe` | pública | fragmento HTML do modal | `Routes/web.php:12-15` |

> Não há rota de API, comando artisan, throttle específico nem nome de rota alternativo. `reorder` não tem UI no índice (endpoint órfão).

## 6. Dados e modelo de domínio

Tabela única `v2_parceiros_institucionais` (UUID, soft delete) + pivô `v2_parceiro_tag_pivot` + tags compartilhadas `v2_conexao_tags`. Detalhamento de colunas, histórico de migrations e efeito do rename/cleanup em [`dados.md`](dados.md).

```mermaid
erDiagram
  v2_parceiros_institucionais ||--o{ v2_parceiro_tag_pivot : "parceiro_id"
  v2_conexao_tags ||--o{ v2_parceiro_tag_pivot : "tag_id"
  v2_gallery_media ||--o{ v2_parceiros_institucionais : "logo_id"
  v2_parceiros_institucionais {
    uuid id PK
    string name
    string slug UK
    text description
    text summary
    string logo "legado"
    string logo_id FK
    string url
    string linkedin_url
    string instagram_url
    string facebook_url
    text address
    string google_maps_url
    jsonb metadata
    int sort_order
    boolean is_active
    string created_by
    timestamp deleted_at
  }
  v2_parceiro_tag_pivot {
    uuid parceiro_id PK
    uuid tag_id PK
    boolean is_primary
  }
```

**Invariantes de dados**: `id` UUID v4 (app), `slug` unique, `sort_order` inteiro sem unique (empates permitidos), `metadata` JSONB nullable (cast `array`), `logo_id` string(36) sem FK, `created_by` preenchido só no store. `updated_at`/`deleted_at` do Laravel.

**Quem escreve**: CMS (`store`/`update`/`toggle`/`reorder`/`destroy`), `DemoContentSeeder` e `PopulateMissingImages` (este só `logo_id`). Ninguém além do seeder escreve `metadata.seo`.

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Ativo: store (sort_order=max+1, is_active=true)
  Ativo --> Inativo: toggle (sort_order=-1, decrementa fila)
  Inativo --> Ativo: toggle (sort_order=max+1)
  Ativo --> Arquivado: destroy (deleted_at)
  Inativo --> Arquivado: destroy (deleted_at)
  Arquivado --> [*]: sem restauração no CMS
```

`FL-PARCEIROSINSTITUCIONAIS-01` (criação): request → validate → new/save (UUID, slug, saved→busca) → syncTags → clearPageCache → activity `created` → redirect índice.

`FL-PARCEIROSINSTITUCIONAIS-02` (edição): findOrFail → validate → atribui (logo vazio→null) → save (slug preservado; busca reindexada) → syncTags → clearPageCache → activity `updated` → redirect edição com flash.

`FL-PARCEIROSINSTITUCIONAIS-03` (arquivamento): findOrFail → `delete()` (soft) → hook `deleted` remove do índice → clearPageCache → activity `archived` → redirect índice. Não há transação envolvendo linha + pivô + busca.

## 8. UI CMS

Quatro telas (`index`, `create`, `edit`, `preview`) e um partial compartilhado. Campos: `name` (obrigatório, ≤255), slug somente leitura, tags (picker com criação), `url`, builder de redes sociais (8 opções), `address`, `google_maps_url` (≤500), `summary` (≤250, contador ao vivo), `description` (rich editor TipTap), `logo_id` (media-input). Sem bulk actions, sem colunas de status/ordem na tabela, sem drag-and-drop. Detalhes, mensagens, JS e a11y em [`cms.md`](cms.md).

## 9. UI Portal

Duas superfícies: página `/parceiros-institucionais` (hero breadcrumb fixo + grade 2 colunas + modal Bootstrap) e fragmento de detalhe usado pelo modal da página **e** pelo carrossel da Homepage. SEO por `SEOService` no controller (`title`/`description`/`keywords`) e `CollectionPage` JSON-LD na view; detalhe não define SEO. Cache de página e HTTP, sitemap e busca em [`portal.md`](portal.md).

## 10. Configuração, permissões e flags

| Chave/flag | Default | Efeito | Fonte |
|---|---|---|---|
| `app.name` | `env('APP_NAME')` | aparece em title/description/keywords do portal (`Parceiros — {app.name}`) | `Controllers/ParceirosInstitucionaisPortalController.php:28-30` |
| `cache.prefix` | `laravel` | prefixo do padrão de invalidação `laravel:page:*` | `Controllers/...CmsController.php:278`; `config/cache.php:89` |
| `filesystems.media_disk` | `filesystems.default` | resolve adapter para `StorageService::url()` do logo | `V2/Services/Storage/StorageService.php:33-43` |
| `v2.support.email` | `config/v2.php` | e-mail com bypass total de RBAC | `V2/Core/Middleware/V2PermissionMiddleware.php:51-72` |
| `module.active:ParceirosInstitucionais` | ausente em `v2_module_flags` → **ativo** | bloqueia CMS quando desativado | `V2/Core/Middleware/ModuleAccessMiddleware.php:40-63`; `V2/Core/Services/ModuleFlagService.php:72-95` |
| permissão `ParceirosInstitucionais` | `none` se ausente | `none` bloqueia; `read` só GET/HEAD; `write` libera | `V2/Core/Middleware/V2PermissionMiddleware.php:80-127` |
| `metadata.seo` | não lido | dívida: nenhum componente consome | `V2/Seeds/Content/DemoContentSeeder.php:1020-1023` |

`ModuleProvider::getConfig()` completo: `title => 'Parceiros Institucionais'`, `description => 'Gerenciamento de parceiros institucionais.'`, `icon => 'bi bi-people'`, `position => 4`, `active_pattern => ['cms/parceiros-institucionais*']`, `route => 'v2.parceiros-institucionais.cms.index'`, um submenu "Todos os Parceiros" (`bi bi-list-ul`, position 1). O `group` é injetado pelo provider como `Conexoes` (`ModuleProvider.php:19-38`; `V2ServiceProvider.php:104-108`).

## 11. Observabilidade, erros e limites

- **Auditoria**: `activity_log` com descrições `created`, `updated`, `archived`; propriedades `type` (`parceiro_create`, `parceiro_update`, `parceiro_archive`, `parceiro_toggle`), `name`, `author` (e-mail), `v2_audit`, `ip_address`, `user_agent` (`V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`).
- **Logs de falha tolerada**: `[Parceiro] Falha ao sincronizar busca: …`, `[Parceiro] Falha ao remover do índice público: …`, `[Parceiro] Erro ao renderizar descrição: …`, `[Parceiro] Falha no syncToSearch: …` (`Models/Parceiro.php:83,91,135,161`) e `[ParceirosInstitucionaisCmsController] Falha ao limpar cache de páginas: …` (`Controllers/...CmsController.php:284`).
- **Logs de permissão**: `[V2 > MIDDLEWARE > Permission] Acesso negado…` / `Tentativa de gravação negada…` (`V2/Core/Middleware/V2PermissionMiddleware.php:86-114`).
- **Códigos**: 200 (páginas/JSON), 302 (redirects/flash), 401 (AJAX sem sessão), 403 (AJAX sem papel/permissão), 404 (`findOrFail`, módulo desativado), 422 de validação (Laravel 5.5 devolve 302 com erros em sessão no fluxo HTML), 500 em exceção de renderer rico.
- **Limites**: paginação 15 (CMS) / 12 (portal); busca sem limite explícito no índice CMS; upload restrito ao `SVC-MEDIA`; sem throttle próprio.

## 12. Seeds e dados iniciais

- `DemoContentSeeder::seedParceiros()` cria 8 parceiros (6 com `logo_id` via `galleryId`, 2 sem) e **pula** se `Parceiro::count() > 0`, com `metadata.social_media` (LinkedIn + Instagram sintéticos) e `metadata.seo` (`V2/Seeds/Content/DemoContentSeeder.php:923-1030`).
- `ParceirosInstitucionaisSeeder` (`class ParceirosSeeder`) **não** semeia a tabela de parceiros: cria uma `v2_basic_pages` com slug `parceiros-institucionais` e HTML estático de 6 logos. Idempotente por `slug` (`V2/Seeds/BasicPages/ParceirosInstitucionaisSeeder.php:63-99`).
- `SucesuHomepageSeeder` cria a seção `sucesu-associados` com `title => 'Parceiros Institucionais'` e sem `source_type` (estática) (`V2/Seeds/BasicPages/SucesuHomepageSeeder.php:310-335`).
- `v2_conexao_tags` recebe a tag reservada `Geral` na migration do core (`V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:48-56`).

## 13. Lacunas

- `LG-PARCEIROSINSTITUCIONAIS-01` — Não há evidência de que `metadata.seo` já tenha sido lido por algum template em versões anteriores; procurar em `REFERENCE/` e histórico Git. Impacto: baixo (campo morto).
- `LG-PARCEIROSINSTITUCIONAIS-02` — O conteúdo original de `logo` (antes de `logo_id`) não tem rotina de migração para a galeria neste módulo; `PopulateMissingImages` só preenche `logo_id` nulo. Onde procurar: migrations de gallery/associados. Impacto: logos antigos podem ficar só no caminho textual.
- `LG-PARCEIROSINSTITUCIONAIS-03` — Não foi possível determinar se o endpoint `reorder` já teve UI (drag-and-drop) removida; não há teste nem JS correspondente. Impacto: médio para paridade de ordenação manual.
- `LG-PARCEIROSINSTITUCIONAIS-04` — Mensagens de validação dependem de arquivos de tradução inexistentes (`resources/lang` ausente); confirmar no servidor de produção se há `lang/` publicado. Impacto: baixo.
- `LG-PARCEIROSINSTITUCIONAIS-05` — O motivo do `class` do seeder divergir do nome do arquivo não está documentado; verificar se há deploy que dependa do classmap. Impacto: baixo.

## 14. Checklist de paridade

- [ ] `PC-PARCEIROSINSTITUCIONAIS-01` — Criar parceiro sem logo: linha com `sort_order = max+1`, `is_active=true`, slug derivado e `created_by` preenchido. Fonte: `Controllers/ParceirosInstitucionaisCmsController.php:76-97`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-02` — Criar dois parceiros com o mesmo `name`: o segundo recebe slug `nome-1`. Renomear o segundo para outro nome mantém `nome-1`. Fonte: `Models/Parceiro.php:60-77`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-03` — Enviar `social_media_json` com item sem `url`: item descartado e não aparece no portal. Fonte: `Controllers/...CmsController.php:294-306`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-04` — Tags `['Ouro','Prata']`: duas linhas em `v2_conexao_tags` (reuso por slug), duas no pivô, `is_primary=1` em Ouro. Fonte: `Models/Parceiro.php:108-124`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-05` — Desativar o segundo ativo: ele fica `sort_order=-1`, o terceiro passa a `2` e o portal deixa de listá-lo. Fonte: `Controllers/...CmsController.php:223-230`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-06` — Reativar: volta com `max(ativos)+1`, não com a ordem original. Fonte: `:232-235`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-07` — Arquivar: `deleted_at` preenchido, pivô intacto, linha removida de `v2_public_search_index`, flash exato. Fonte: `:185-200`; `Models/Parceiro.php:87-93`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-08` — Editar limpando `logo_id`: coluna fica `NULL` e o portal mostra placeholder. Fonte: `:164-168`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-09` — Portal lista somente ativos, 12 por página, ordem `sort_order`+`name`. Fonte: `Controllers/ParceirosInstitucionaisPortalController.php:26`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-10` — `GET /detalhe/{id}` de inativo retorna 200 (sem filtro `active`). Fonte: `:38-50`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-11` — Salvar qualquer campo limpa `laravel:page:*`; segunda requisição ao portal é `X-Cache-Status: MISS` e a seguinte `HIT`. Fonte: `Controllers/...CmsController.php:274-286`; `V2/Core/Middleware/PageCacheMiddleware.php:35-59`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-12` — Widget `sucesu-associados` com 2 ativos gera 6 slides (lista repetida). Fonte: `V2/Modules/Layouts/Homepage/LseWidgets/Widgets/SucesuAssociadosWidget.php:53-59`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-13` — `hidden_slugs` oculta exatamente o slug indicado, sem esconder novos parceiros. Fonte: `:30-39`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-14` — Sitemap contém `/parceiros-institucionais` (0.5/weekly) e um `/detalhe/{id}` por parceiro não deletado. Fonte: `V2/Services/SEO/Controllers/SeoPortalController.php:258-280`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-15` — Busca pública por nome de parceiro ativo devolve URL `/parceiros-institucionais/detalhe/{id}`; inativo não aparece. Fonte: `Models/Parceiro.php:140-159`; `V2/Services/Search/Services/SearchService.php:83-84`.
- [ ] `PC-PARCEIROSINSTITUCIONAIS-16` — Permissão `read`: GET permitido, POST `novo` redireciona com mensagem de leitura. Fonte: `V2/Core/Middleware/V2PermissionMiddleware.php:102-122`.

## 15. Evidências consultadas

| Arquivo | Faixas |
|---|---|
| `V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisCmsController.php` | 17-307 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisPortalController.php` | 1-50 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Models/Parceiro.php` | 1-176 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/ModuleProvider.php` | 1-39 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Routes/cms.php` | 1-52 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Routes/web.php` | 1-16 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Migrations/*` | 7 arquivos, íntegra |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Views/cms/*.blade.php` | 1-389, 1-223, 1-224, 1-167, 1-161 |
| `V2/Modules/Conexoes/ParceirosInstitucionais/Views/portal/*.blade.php` | 1-225, 1-72 |
| `V2/Core/Providers/V2ServiceProvider.php` | 85-150, 285-315 |
| `V2/Core/Middleware/CmsAuthenticate.php`, `V2PermissionMiddleware.php`, `ModuleAccessMiddleware.php`, `PageCacheMiddleware.php`, `PublicCacheMiddleware.php` | íntegra |
| `V2/Core/Models/ConexaoTag.php`; `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php`; `V2/Core/Controllers/ConexaoTagCmsController.php` | íntegra |
| `V2/Services/Search/Services/{PublicIndexerService,SearchService}.php`; `V2/Services/Search/DTOs/PublicSearchDTO.php` | 1-100 / 1-90 / íntegra |
| `V2/Services/SEO/Controllers/SeoPortalController.php` | 240-285 |
| `V2/Modules/Layouts/Homepage/{Services/HomepageDataIntegrator.php,Services/HomepageSectionResolver.php,LseWidgets/Widgets/SucesuAssociadosWidget.php,Views/lse/render/associados.blade.php,Views/lse/forms/associados.blade.php}` | recortes citados |
| `V2/Seeds/BasicPages/ParceirosInstitucionaisSeeder.php`; `V2/Seeds/Content/DemoContentSeeder.php` | 1-122 / 920-1035 |
| `V2/Services/Storage/StorageService.php`; `V2/Services/Media/Views/components/{picture,media-input}.blade.php` | recortes citados |
| `V2/Services/Dashboard/DashboardStatsService.php` | 160-180, 655-675 |
