---
id: MOD-CONEXOES-ASSOCIADOS
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-CONEXOES-ASSOCIADOS]
fontes:
  - V2/Modules/Conexoes/Associados/
depende_de: [CORE-BOOT, CORE-AUTH, CORE-CMS, CORE-DATA, CORE-RUNTIME, SVC-GALLERY, SVC-MEDIA, SVC-SEO, SVC-SEARCH, SVC-STORAGE, SVC-ACTIVITYLOGGER, SVC-CONTENTRICHEDITOR, MOD-SUPPORT-SUPPORTCONFIGS, MOD-CONEXOES-UNIDADEREGIONAL]
consumidores: [UI-CMS, UI-PORTAL, MOD-CONEXOES-UNIDADEREGIONAL]
criticidade: alta
dono: Associados
---

# Conexões/Associados

> Instituições conveniadas: cadastro no CMS (tags, regional, logo/capa, benefícios,
> redes sociais, galeria) e vitrine no portal (lista com filtros + modal + página
> dedicada por slug). O módulo é **regional-owned**: o vínculo com a Unidade Regional
> vive num pivô, e o operador regional só vê e edita o que está vinculado à sua unidade.
> O ponto mais contraintuitivo é o **catálogo de tags paralelo**: a tela exibe o modal
> de tags do Core, mas o domínio grava e lê `v2_associado_tags`.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Uma instituição conveniada passa a existir (ou é arquivada/restaurada) no CMS, com tags, regional, mídias e benefícios, e aparece — quando `is_active = true` — na listagem `/associados`, no modal de detalhe e na página `/associado/{slug}` do portal. |
| Entrada | 13 rotas CMS sob `cms/associados` (`V2/Modules/Conexoes/Associados/Routes/cms.php:3-75`); 3 rotas públicas (`.../Routes/web.php:10-27`); parâmetros de formulário `name`, `tags[]`, `unidade_regional_id`, `url`, `discount_badge`, `description`, `convenio_description`, `benefits[]`, `social_media_json`, `logo_id`/`gallery_logo_id`, `cover_image_id`/`gallery_cover_image_id`, `convenios_gallery_ids[]`, `is_active`, `sort_order`; query pública `search`, `tags[]`, `regional`. |
| Saída | HTML de 4 telas CMS + fragmento de preview; HTML do portal (lista, página e fragmento de modal); JSON `{success, count}` no sync de galeria; `302` com flash `success`/`error`; linhas em `v2_associado_instituicoes`, `v2_associado_instituicao_gallery`, `v2_associado_tags`, `v2_associado_tag_pivot`, `v2_unidade_regional_empresas`, `v2_portal_configs`, `activity_log` e `v2_public_search_index`. |
| Efeitos colaterais | `activity_log` (7 tipos de evento), `Log::info` estruturado em create/update/destroy, `Log::warning` de cache, remoção/atualização do índice público de busca, invalidação de todo o cache Redis `page:*` do portal, criação automática de tags, abertura/fechamento de vínculo regional. |
| Particularidade central | Dois catálogos de tags incompatíveis: o modal compartilhado do CMS escreve `v2_conexao_tags` (Core), enquanto todo o domínio de Associados lê/escreve `v2_associado_tags` — a "Gerenciar Tags" da tela não afeta o seletor da própria tela (`V2/Modules/Conexoes/Associados/Views/cms/instituicoes/index.blade.php:38-49,66-70`; `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:108,188`). |
| Como se integra | Namespace `V2\Modules\Conexoes\Associados`; views `v2-associados::`; rotas nomeadas `v2.associados.*`; tabelas `v2_associado_*`; serviços `GalleryService`, `GalleryConfig`, `GalleryMedia`, `SEOService`, `RegionalScopeService`, `ContentRichEditorRendererService`, `PortalConfigsService`, `PublicIndexerService`, `ActivityLoggerService`, `StorageService`. |
| Se removido, quebra | `/associados`, `/associado/{slug}` e o modal de detalhe passam a 404 (o fallback `/{slug}` do BasicPage não resolve); o menu do CMS perde "Associados Regionais"; o módulo UnidadeRegional perde os endpoints `/api/v2/unidaderegional/associados*` e o perfil regional perde sua única área de escrita além de Notícias; o índice público perde o módulo `associados`. |
| Superfície pública | `AssociadoInstituicao` (+ escopos e hooks), `AssociadoInstituicaoGallery`, `AssociadoTag`, `InstituicaoCmsController`, `AssociadosPortalController`, `ModuleProvider`, views `v2-associados::cms.*` e `v2-associados::portal.*`. |

## 1. Efeito

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

- **Operador nacional (write)**: listar instituições ativas (`GET /cms/associados/instituicoes`, 15 por página), buscar por nome/descrição, filtrar por tag, criar, editar, visualizar preview, arquivar, listar arquivadas, restaurar, enviar/sincronizar/remover imagens da galeria e definir a imagem de fundo da página pública.
- **Operador regional** (`users.unidade_regional_id` preenchido, role `regional` com `Associados: write`): vê apenas instituições vinculadas à sua regional (`RegionalScopeService::applyScope`), cria já vinculado à própria regional (campo travado), e recebe `403`/redirect ao tentar editar/arquivar/gerenciar galeria de instituição de outra regional. Não acessa arquivadas, restauração nem a imagem de fundo (`RegionalRestrictMiddleware`).
- **Perfil `read`**: abre listagem e edição, mas POST/PUT/DELETE é barrado pelo `V2PermissionMiddleware` com `Operação negada. Seu perfil possui apenas permissão de leitura para o módulo: Associados Regionais` (ou `403` em AJAX).
- **Público**: `/associados` lista apenas `is_active = true`; busca por nome (`ilike`), marca uma ou mais tags e escolhe regional; sem filtros há paginação de 12, com qualquer filtro a lista é integral (sem paginação) e agrupada pela tag primária; o card abre um modal com detalhe carregado por XHR; `/associado/{slug}` é a página dedicada para SEO/links externos.

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| Insere/atualiza a instituição, gera UUID v4 e slug único (sufixo `-1`, `-2`…), grava `created_by` no create | `store()`/`update()` + hooks do model | `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:114-181,195-265`; `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:177-202` |
| Cria tags ausentes por slug e substitui o conjunto do pivô, marcando a primeira como `is_primary` | `syncTagsFromArray()` | `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:231-252` |
| Cria/reaponta o vínculo em `v2_unidade_regional_empresas` com `status = 'active'` | `syncUnidadeRegional()` | `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:374-396` |
| Copia `file_path` da mídia para `logo`/`cover_image` e guarda `logo_id`/`cover_image_id` em `metadata` | `handleLogoFromGallery()`/`handleCoverFromGallery()` | `.../InstituicaoCmsController.php:398-438` |
| Grava/remove linhas da galeria e mantém `image` + `gallery_media_id` | `uploadGallery()`, `syncGallery()`, `syncGalleryMediaIds()`, `destroyGallery()` | `.../InstituicaoCmsController.php:317-371,440-503` |
| Arquiva: `is_active = false` + soft delete; remove do índice público | `destroy()` + hook `deleted` | `.../InstituicaoCmsController.php:267-291`; `.../Models/AssociadoInstituicao.php:212-218` |
| Restaura o soft delete (sem reativar nem reindexar) | `restore()` | `.../InstituicaoCmsController.php:89-104` |
| Reindexa o portal a cada `save` (ou remove se inativo) | hook `saved` + `syncToSearch()` | `.../Models/AssociadoInstituicao.php:204-210,254-300` |
| Grava `theme_associados_bg` em `v2_portal_configs` e audita a alteração | `saveBackgroundImage()` + `PortalConfigsService::save` | `.../InstituicaoCmsController.php:506-526`; `V2/Modules/Support/SupportConfigs/Services/PortalConfigsService.php:101-129` |
| Invalida **todas** as páginas do portal no Redis (`<prefix>:page:*`) | `clearPageCache()` | `.../InstituicaoCmsController.php:572-583` |
| Registra auditoria em `activity_log` | `ActivityLoggerService::log` | `.../Controllers/InstituicaoCmsController.php:96-99,167-170,255-258,281-284,344-348,474-478,496-500`; `V2/Services/ActivityLogger/ActivityLoggerService.php:20-42` |

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

| Entrada | Resultado |
|---|---|
| `POST cms/associados/instituicoes/nova` com `name`, ≥1 `tags[]`, `unidade_regional_id` existente | `302` para o index, flash `created_item` (modal de sucesso); linha criada com `is_active` default `true` |
| `POST/PUT cms/associados/instituicoes/editar/{id}` | `302` de volta para a edição + `Instituição atualizada com sucesso!` |
| `DELETE cms/associados/instituicoes/{id}` | `302` para o index + `Instituição "NOME" arquivada com sucesso!`; linha soft-deleted e inativa |
| `POST cms/associados/instituicoes/{id}/restaurar` | `302` para arquivadas + `Instituição "NOME" restaurada com sucesso!`; permanece inativa |
| `POST .../{id}/gallery` com `images[]` (jpeg/png/jpg/webp ≤ 4096 KB) | `302` back + `Imagens enviadas com sucesso!`; uma linha por arquivo |
| `POST .../{id}/gallery/sync` com `media_ids[]` | `200 {"success":true,"count":N}` (N = itens enviados, não os efetivamente gravados) |
| `DELETE cms/associados/gallery/{galleryId}` | `302` back + `Imagem removida da galeria com sucesso!` |
| `POST cms/associados/salvar-imagem-fundo` com `image_id` inexistente | `302` back + erro `Imagem não encontrada na galeria.` |
| Validação de `name`/`tags`/`unidade_regional_id` | `302` back com `errors` (mensagens padrão do Laravel) |
| Regional operator em `GET .../arquivadas`, `POST .../restaurar` ou `POST .../salvar-imagem-fundo` | `302` para `v2.cms.dashboard` + `Acesso negado. Esta ação é restrita à administração nacional.`; em AJAX/JSON `403 Forbidden. Ação restrita à administração nacional.` |
| Regional operator em edição de instituição de outra regional | `403 Acesso restrito aos dados da sua Unidade Regional.` |
| `GET /associados/detalhe/{id}` de instituição inativa/arquivada | `404` (`active()->findOrFail`) |
| `GET /associado/{slug}` inexistente ou inativo | `404` (`active()->where('slug')->firstOrFail`) |

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

- Não cria, renomeia nem exclui tags do catálogo de Conexões (`v2_conexao_tags`); o modal do Core que aparece na tela é de outro domínio.
- Não apaga arquivos de mídia ao remover uma linha da galeria; apenas o vínculo `v2_associado_instituicao_gallery` é apagado.
- Não publica aprovação, rascunho ou workflow editorial: `is_active` é o único estado público.
- Não envia e-mail, feed ou notificação.
- Não oferece campos de SEO no CMS; `metadata['seo']` só é preenchido por seeder/API.
- Não permite reativar uma instituição arquivada pela UI (não há checkbox de `is_active`).
- Não valida o tamanho da descrição curta no servidor (o `maxlength="250"` é só cliente).
- Não expõe API própria de instituições: a API que cria/edita associados pertence a UnidadeRegional e autentica por token da regional.

## 2. Abordagem

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

O módulo trata a instituição como registro nacional com **vínculo regional em pivô** e **mídias por referência** (GUID da galeria + cópia do path). O formulário CMS é a única superfície de escrita; o portal é somente leitura e sempre filtra `is_active`. Tags são texto livre normalizado por `str_slug` e `firstOrCreate`, formando um catálogo próprio. A consistência externa é resolvida por efeitos best-effort: índice de busca no hook `saved`, cache de página limpo manualmente e auditoria via `activity_log`.

```mermaid
flowchart LR
  A[CMS form] --> B[validate]
  B --> C[AssociadoInstituicao]
  C --> D[(v2_associado_instituicoes)]
  C --> E[syncTagsFromArray]
  E --> F[(v2_associado_tags + pivot)]
  C --> G[syncUnidadeRegional]
  G --> H[(v2_unidade_regional_empresas)]
  C --> I[hooks saved/deleted]
  I --> J[(v2_public_search_index)]
  C --> K[clearPageCache]
  K --> L[(Redis page:*)]
  D --> M[Portal /associados e /associado/slug]
```

### 2.2 Mecânica passo a passo

1. `V2ServiceProvider` descobre `Modules/Conexoes/Associados`, registra migrations, views `v2-associados` e rotas; rotas CMS recebem `web` + `module.active:Associados`; rotas web recebem `web` + `MaintenanceMiddleware` + `page.cache` + `public.cache` (`V2/Core/Providers/V2ServiceProvider.php:293-308`).
2. `CmsAuthenticate` exige Sentinel + ao menos um papel e delega ao `V2PermissionMiddleware`, que casa `active_pattern` (`['cms/associados*']`) com a chave de módulo `Associados` (`V2/Core/Middleware/CmsAuthenticate.php:18-45`; `V2/Modules/Conexoes/Associados/ModuleProvider.php:21`; `V2/Core/Middleware/V2PermissionMiddleware.php:40-48,79-128`).
3. `index()` monta `with(['tags','unidadesRegionais'])`, aplica escopo regional, busca `ilike` em `name`/`description`, filtra por `v2_associado_tags.id` e pagina em 15 (`InstituicaoCmsController.php:47-70`).
4. `create()` carrega todas as tags e as regionais permitidas (operador regional vê só a própria) (`:106-112,558-566`).
5. `store()` valida, instancia, define `id = v2_uuid()`, campos escalares, `created_by`, benefícios (array filtrado por `name`), `social_media_json` decodificado, logo/capa a partir de `GalleryMedia`, e salva. Os hooks de model derivam o slug quando vazio e garantem a unicidade (`saving`) e completam o id quando ausente (`creating`); o `saved` indexa na busca (`:114-181`; model `:177-210`).
6. `syncTagsFromArray()` normaliza cada nome, cria a tag ausente e substitui o pivô; a primeira tag do array vira `is_primary` (`model:231-252`).
7. `syncUnidadeRegional()` força a regional do operador quando aplicável e cria/reaponta a linha do pivô com `status='active'` (`:374-396`).
8. `clearPageCache()` apaga `page:*` no Redis; `activityLogger->log()` grava `created`; `Log::info` grava contexto de auditoria; flash e redirect (`:165-180`).
9. `update()` repete o fluxo, acrescenta `syncGalleryMediaIds()` com `convenios_gallery_ids[]`, audita `updated` e redireciona para a própria edição (`:195-265`).
10. `destroy()` desativa, salva, faz soft delete, limpa cache, audita `deleted` e volta ao index (`:267-291`). `restore()` usa `onlyTrashed()->findOrFail`, restaura, limpa cache, audita `restored` e volta para arquivadas (`:89-104`).
11. Portal: `index()` sanitiza tags por regex UUID, filtra ativos, ordena e pagina; `detalhe($id)` devolve fragmento HTML para o modal; `show($slug)` monta a página SEO (`AssociadosPortalController.php:28-114,122-158,160-198`).

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

- **Vínculo regional por pivô, não coluna** `[C]`: permite N:N (a API de UnidadeRegional vincula a mesma instituição a várias regionais), mas o CMS colapsa para uma (`syncUnidadeRegional` atualiza a linha encontrada por `empresa_id`) (`Controllers/InstituicaoCmsController.php:382-393`; `Models/AssociadoInstituicao.php:74-82`).
- **Escopo regional por contrato** `[C]`: o Core não importa models de módulo; `AssociadoInstituicao implements RegionalOwnable` e fornece `scopeByUnidadeRegional`, `belongsToUnidadeRegional` e `getUnidadeRegionalId` (`Models/AssociadoInstituicao.php:13,90-128`).
- **Mídia por referência dupla** `[I]`: `gallery_media_id` preserva o registro da Galeria (variantes/alt), `image` mantém o path legado e serve de fallback quando a mídia não existe (`Models/AssociadoInstituicaoGallery.php:38-55`; views do portal `portal/detalhe.blade.php:117-127`).
- **Metadados como saco de extensão** `[C]`: benefícios e redes sociais vivem em `metadata` via accessors, não em colunas; `logo_id`/`cover_image_id`/`seo` também (`Models/AssociadoInstituicao.php:43-46,131-173`).
- **Tags como taxonomia própria** `[C]`: a migração Core que copia tags para `v2_conexao_tags` não migra os pivôs de Associados (`V2/Core/Migrations/2026_08_18_000002_migrate_associado_tags_to_conexao_tags.php:18-48`).
- **Cache invalidado por varredura** `[C]`: não há chave por entidade; qualquer escrita apaga todas as páginas (`page:*`) (`Controllers/InstituicaoCmsController.php:572-583`).

### 2.4 Trechos de código pivô

```php
// V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:231-252 — tags: catálogo próprio e "primeira = primária"
public function syncTagsFromArray(array $tagNames)
{
    $tagIds = [];
    $isFirst = true;

    foreach ($tagNames as $name) {
        $name = trim($name);
        if (empty($name)) continue;

        $slug = str_slug($name);
        $tag = AssociadoTag::firstOrCreate(
            ['slug' => $slug],
            ['name' => $name, 'id' => v2_uuid()]
        );

        $tagIds[$tag->id] = ['is_primary' => $isFirst];
        $isFirst = false;
    }

    $this->tags()->sync($tagIds);
    return $this;
}
```

Este é o núcleo da taxonomia: o nome digitado vira slug, a tag é criada sob demanda e a ordem do array define a tag primária usada para agrupar o portal. `sync` remove do pivô tudo o que não está no array submetido, mas **não** remove a tag do catálogo.

```php
// V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:374-396 — regional: operador manda, CMS colapsa o pivô em uma linha
protected function syncUnidadeRegional(AssociadoInstituicao $instituicao, Request $request)
{
    $urId = $request->input('unidade_regional_id');

    if ($this->regionalScope->isRegionalOperator()) {
        $urId = $this->regionalScope->regionalId();
    }

    if (!empty($urId)) {
        $existing = \V2\Modules\Conexoes\UnidadeRegional\Models\UnidadeRegionalEmpresa::where('empresa_id', $instituicao->id)->first();

        if ($existing && $existing->unidade_regional_id !== $urId) {
            $existing->unidade_regional_id = $urId;
            $existing->save();
        } elseif (!$existing) {
            $rel = new \V2\Modules\Conexoes\UnidadeRegional\Models\UnidadeRegionalEmpresa();
            $rel->id = v2_uuid();
            $rel->unidade_regional_id = $urId;
            $rel->empresa_id = $instituicao->id;
            $rel->status = 'active';
            $rel->save();
        }
    }
}
```

O vínculo é sempre reescrito; um operador regional não consegue "devolver" a instituição para outra unidade. Como a busca por `empresa_id` ignora soft-deleted (o model usa `SoftDeletes`), uma relação soft-deletada não é reencontrada e a nova inserção pode violar a unique `(unidade_regional_id, empresa_id)`.

```php
// V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:440-472 — galeria: apaga sobras e cria novas por mídia
protected function syncGalleryMediaIds(AssociadoInstituicao $instituicao, array $mediaIds)
{
    $mediaIds = array_values(array_filter($mediaIds, function ($id) {
        return is_numeric($id) && (int) $id > 0;
    }));
    $mediaIds = array_map('intval', $mediaIds);

    AssociadoInstituicaoGallery::where('instituicao_id', $instituicao->id)
        ->whereNotNull('gallery_media_id')
        ->whereNotIn('gallery_media_id', $mediaIds)
        ->delete();

    $existingIds = AssociadoInstituicaoGallery::where('instituicao_id', $instituicao->id)
        ->whereNotNull('gallery_media_id')->pluck('gallery_media_id')->toArray();

    foreach ($mediaIds as $mediaId) {
        if (empty($mediaId)) continue;
        if (in_array($mediaId, $existingIds)) continue;

        $galleryMedia = GalleryMedia::find($mediaId);
        if (!$galleryMedia) continue;

        $galleryItem = new AssociadoInstituicaoGallery();
        $galleryItem->id = v2_uuid();
        $galleryItem->instituicao_id = $instituicao->id;
        $galleryItem->image = $galleryMedia->file_path;
        $galleryItem->gallery_media_id = $mediaId;
        $galleryItem->sort_order = AssociadoInstituicaoGallery::where('instituicao_id', $instituicao->id)->count() + 1;
        $galleryItem->save();
    }
}
```

A lista submetida é a fonte da verdade para linhas **com** `gallery_media_id`; linhas legadas (id nulo) nunca são apagadas por sync. Não há transação: uma falha no meio deixa a galeria parcialmente sincronizada.

```php
// V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:177-210 — hooks: id/slug, buscas e remoção
static::creating(function ($model) {
    if (empty($model->id)) {
        $model->id = v2_uuid();
    }
    if (empty($model->slug) && !empty($model->name)) {
        $model->slug = str_slug($model->name);
    }
});

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++;
        }
    }
    $model->slug = $slug;
});

static::saved(function ($model) {
    try { $model->syncToSearch(); } catch (\Exception $e) { /* log */ }
});
```

O slug só é derivado do nome quando ainda está vazio; em updates o `saving` parte do slug existente, então renomear **não** altera a URL pública. A colisão com outro registro recebe sufixo `-N`. O `saving` roda antes de `creating`; a unicidade é resolvida nele e o `creating` só preenche o que estiver vazio (id e, se ainda faltar, slug).

```php
// V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:254-300 — índice público com tags de regional
public function syncToSearch()
{
    if (!$this->is_active) {
        app(PublicIndexerService::class)->remove(get_class($this), $this->id);
        return;
    }

    $tags = ['associado', 'convenio', 'instituicao'];
    $descricaoTextoPlano = $this->extractTextFromEditorJson($this->convenio_description);
    $deepContent = trim(($this->description ?? '') . ' ' . $descricaoTextoPlano . ' ' . ($this->benefits_list ?? ''));

    $regionalNames = [];
    foreach ($this->unidadesRegionais()->get() as $regional) {
        $tags[] = 'regional';
        if (!empty($regional->acronym)) $tags[] = $regional->acronym;
        if (!empty($regional->slug)) $tags[] = $regional->slug;
        if (!empty($regional->state)) $tags[] = $regional->state;
        $regionalNames[] = trim($regional->name . ' ' . ($regional->acronym ?? '') . ' ' . ($regional->state ?? ''));
    }

    $dto = new PublicSearchDTO($this->name, $this->slug, 'associados', get_class($this), $this->id,
        $this->description ?: 'Instituição associada', array_values(array_unique($tags)), $deepContent);
    app(PublicIndexerService::class)->index($dto);
}
```

O texto do editor Tiptap é achatado recursivamente antes de ir ao índice; sigla, slug e UF da regional entram como tags de busca. A indexação é best-effort: falha é logada e não impede o save.

```php
// V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:572-583 — invalidação global do cache de páginas
private function clearPageCache()
{
    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('[InstituicaoCmsController] Falha ao limpar cache de páginas: ' . $e->getMessage());
    }
}
```

`keys` é O(N) e apaga o cache de todo o portal, não só de `/associados`; indisponibilidade do Redis é fail-open (log e segue). O TTL real do `PageCacheMiddleware` é 5 minutos e a chave é `page:sha1(fullUrl)` (`V2/Core/Middleware/PageCacheMiddleware.php:11-14,31-56,88-90`).

```php
// V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:34-60 — filtros públicos só aceitam UUID
foreach ($activeTagsRaw as $tagId) {
    $tagId = trim((string) $tagId);
    if ($tagId !== '' && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $tagId)) {
        $safeTags[] = $tagId;
    }
}
$activeTags = $safeTags;

$query = AssociadoInstituicao::active();

if ($activeSearch !== '') {
    $query->where('name', 'ilike', '%' . $activeSearch . '%');
}

if (!empty($safeTags)) {
    $query->whereHas('tags', function ($q) use ($safeTags) {
        $q->whereIn('v2_associado_tags.id', $safeTags);
    });
}
```

O portal protege a query contra lixo no query string (não tenta `whereIn` com strings arbitrárias) e reconstrói `$activeTags` para re-renderizar os checkboxes; qualquer tag inválida é simplesmente descartada, não gera erro.

### 2.5 Algoritmos, consultas e custos

- **Listagem CMS**: uma consulta paginada + eager loading de tags e regionais (`with`), sem N+1 na tabela. `paginate(15)`.
- **Filtro por tag**: `whereHas('tags')` gera `exists` sobre `v2_associado_tag_pivot` + `v2_associado_tags`; há índice em `tag_id` e `associado_id`.
- **Portal com filtros**: `withCount('tags as matched_tags_count')` e `ORDER BY matched_tags_count DESC, sort_order, name`, mas `get()` sem limite — o custo cresce com o catálogo.
- **Portal sem filtros**: `ordered()->paginate(12)`; `count(*)` do paginator + select.
- **Escopo regional**: `whereHas('unidadesRegionais')` (índices `idx_v2_unr_emp_regional`/`empresa` e unique composta).
- **Galeria**: `sort_order = count(*) + 1` dentro do loop → uma query de contagem por item; leitura ordenada por `sort_order`.
- **Busca**: UPSERT por `(entity_type, entity_id)` no índice público (`V2/Services/Search/Services/PublicIndexerService.php:21-56`); o índice tem trigger de `search_vector` (ver SVC-SEARCH).
- **Cache**: toda página pública é cacheada por 5 min no Redis; o fragmento do modal é buscado por XHR sem `X-Requested-With` e, portanto, também entra no cache de página.

## 3. Particularidades

### 3.1 O que só existe aqui

- **Catálogo de tags próprio** (`v2_associado_tags` + `v2_associado_tag_pivot`), sem `is_reserved`, sem exclusão pelo Core e com criação implícita a partir do formulário (`Models/AssociadoTag.php:9-22`; migration `2024_01_01_000016:17-33`).

**Comparação dos dois catálogos** (a fonte real de Associados é a primeira coluna):

| Dimensão | `v2_associado_tags` (fonte real de Associados) | `v2_conexao_tags` (modal do Core) |
|---|---|---|
| Model | `V2\Modules\Conexoes\Associados\Models\AssociadoTag` | `V2\Core\Models\ConexaoTag` |
| Pivôs | `v2_associado_tag_pivot` | `v2_parceiro_tag_pivot`, `v2_unidade_regional_tag_pivot` |
| Consumidores | CMS e portal de Associados | Parceiros, Unidades Regionais e o modal `conexao-tags-modal` |
| Reservada | não | `Geral` (`is_reserved=true`) |
| Exclusão | não existe UI | `DELETE /cms/conexao-tags/{id}` reatribui para `Geral` |

- **Instituição como elo nacional↔regional**: é o único módulo que materializa a empresa do portal como registro "nacional" e a vincula a N unidades regionais por `v2_unidade_regional_empresas`.
- **Dupla representação de imagem de capa**: `cover_image` (path) + `metadata.cover_image_id` (id da galeria) + `theme_associados_bg` (fundo global da listagem).
- **Página de fundo configurável só para esta listagem**: `theme_associados_bg` é uma chave de `v2_portal_configs` usada apenas por `/associados` (`AssociadosPortalController.php:96-102`).

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-ASSOCIADOS-01` | Tags de Associados e de Conexões são catálogos independentes; o modal "Gerenciar Tags" grava `v2_conexao_tags` e o domínio lê `v2_associado_tags`. | Criar/excluir no modal não altera o seletor nem os vínculos das instituições. **Fonte real: `v2_associado_tags`.** | `V2/Modules/Conexoes/Associados/Views/cms/instituicoes/index.blade.php:38-49,66-70`; `V2/Core/Models/ConexaoTag.php:10`; `V2/Modules/Conexoes/Associados/Models/AssociadoTag.php:9` |
| `RN-ASSOCIADOS-02` | `tags` é obrigatório com ≥1 item de até 100 chars; a ordem enviada define a tag primária. | Instituição sem tag não é criada; a primeira tag agrupa o card no portal. | `InstituicaoCmsController.php:116-119,201-204`; `Models/AssociadoInstituicao.php:234-250` |
| `RN-ASSOCIADOS-03` | Slug é calculado apenas quando vazio; renomear preserva a URL; colisão recebe `-N`. | O slug é estável após a criação; links antigos continuam válidos. | `Models/AssociadoInstituicao.php:177-202` |
| `RN-ASSOCIADOS-04` | `unidade_regional_id` é obrigatório e precisa existir; operador regional tem o valor sobrescrito pela própria unidade. | Não existe instituição sem regional pela UI; operador não escolhe outra. | `InstituicaoCmsController.php:125,377-379`; `_form.blade.php:99-121` |
| `RN-ASSOCIADOS-05` | Arquivar = `is_active=false` + soft delete; restaurar não reativa e não reindexa. | Após restaurar, a instituição continua invisível no portal e o formulário reenvia `is_active=0`. | `InstituicaoCmsController.php:275-277,89-104`; `_form.blade.php:254` |
| `RN-ASSOCIADOS-06` | Rotas de arquivadas, restauração e fundo são "nacional-only". | Operador regional recebe redirect/403 mesmo com `write`. | `Routes/cms.php:13-23,70-74`; `V2/Core/Middleware/RegionalRestrictMiddleware.php:36-50` |
| `RN-ASSOCIADOS-07` | `update()` assume `is_active=false` quando o campo não vem; `store()` assume `true`. | Chamadas fora do formulário (sem hidden) desativam a instituição no update. | `InstituicaoCmsController.php:141,228` |
| `RN-ASSOCIADOS-08` | Sync de galeria só remove linhas com `gallery_media_id` não nulo; não apaga mídia/arquivo. | Órfãos de mídia e linhas legadas permanecem. | `InstituicaoCmsController.php:448-451` |
| `RN-ASSOCIADOS-09` | Portal só considera `is_active = true`; detalhe por id e página por slug. | Instituição inativa gera 404 no modal e na página. | `Models/AssociadoInstituicao.php:348-351`; `AssociadosPortalController.php:124-126,162-165` |
| `RN-ASSOCIADOS-10` | O token de API não é da instituição: vive em `v2_unidade_regionais` e autentica a regional que cria/edita associados. | Não há token por associado; `v2.associados.api_token_ttl_days` é órfão. | `Migrations/2024_01_01_000014_add_api_token_to_associado_instituicoes.php:6-18`; `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:277-300`; `config/v2.php:17-19` |
| `RN-ASSOCIADOS-11` | Upload de galeria aceita apenas jpeg/png/jpg/webp até 4096 KB. | Outros formatos retornam erro de validação; não há PDF/áudio. | `InstituicaoCmsController.php:323-328` |
| `RN-ASSOCIADOS-12` | `description` tem limite de 250 apenas no cliente; servidor aceita texto arbitrário. | Payloads diretos podem gravar descrições longas. | `create.blade.php:28-31`; `InstituicaoCmsController.php:126` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Duas inclusões do modal de tags** na mesma página (`index.blade.php:49,232`): dois elementos com `id="conexaoTagModal"`; o JS pega o primeiro e o segundo fica inerte, mas ambos executam a query de tags no render. O botão "Gerenciar Tags" também aparece na tela de arquivadas.
- **Tag digitada só com caracteres não latinos**: `str_slug` pode produzir string vazia; `firstOrCreate(['slug' => ''])` colide com a unique e gera erro 500.
- **Nome > 100 chars**: o campo `tags.*` limita a 100, mas o slug pode exceder 100 e estourar a coluna `varchar(100)` no Postgres.
- **Tag existente com caixa diferente**: `str_slug` normaliza e reutiliza a tag antiga, preservando o `name` original.
- **`benefits` no `store`** não tem regra de validação (só no `update`); itens sem `name` são descartados; o link só é validado como URL no update.
- **`social_media_json` inválido** em `store/update` vira `[]` e apaga as redes sociais anteriores (`:150-153,236-239`).
- **Logo/capa com id inexistente** são ignorados silenciosamente e o path antigo é mantido (`:398-438`).
- **Exclusão de imagem da galeria** não verifica se a instituição está ativa nem se o usuário é nacional; só autoriza pela regional do pai, quando existe.
- **Preview do CMS**: não é "live"; o iframe carrega a URL uma vez e nenhum JS escuta mudanças no editor (`edit.blade.php:131-133`, sem handler).
- **Restauração**: `restore()` não dispara `saved`, então o índice público não recebe a instituição de volta; como `is_active` ficou `false`, ela também não aparece no portal.
- **API de UnidadeRegional** lê `$empresa->category`, coluna removida pela migration `000020`; o valor é sempre `null` (`V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:75`).

### 3.4 Armadilhas de reimplementação

- **Não reaproveitar o modal de tags do Core** para o seletor de Associados sem trocar o endpoint: o modal só fala com `v2.cms.conexao-tags.*` (`V2/layouts/cms/partials/conexao-tags-modal.blade.php:2-5,80-81`).
- **A ordem das tags importa**: se o seletor for refeito como conjunto, perde-se a noção de tag primária e o agrupamento do portal muda.
- **`saving` roda antes de `creating`**: replicar apenas o `creating` não deduplica slugs em criações com nome repetido.
- **`syncUnidadeRegional` não procura com `withTrashed`** e o pivô tem unique composta: reinserir após soft delete manual falha.
- **`clearPageCache` pressupõe prefixo `cache.prefix`**; usar `Cache::store('redis')->flush()` muda o escopo e apaga mais do que o portal.
- **O portal depende de `v2_storage_url`** (StorageService), não de `asset()`; caminhos gravados são relativos ao disco `media`.
- **`preview` usa o layout do portal dentro de rota CMS**; não há `CmsAuthenticate` na view em si, mas a rota exige sessão.

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

- **`type`/regionais**: a migration `2024_01_01_000012` é no-op e declara que os campos foram movidos para `v2_unidade_regionais` (`Migrations/2024_01_01_000012:6-23`). Não existe coluna `type` em `v2_associado_instituicoes` nem em `v2_unidade_regionais`; o "tipo" é o vínculo no pivô + `metadata.category` gravado pela API (`'category' => 'regional'`).
- **Token de API por instituição**: a migration `000014` também é no-op (`Migrations/2024_01_01_000014:6-23`). O token real é por Unidade Regional (`api_token_hash`, prefixo, expiração) e o `config/v2.php` mantém uma chave `v2.associados.api_token_ttl_days` que ninguém lê.
- **`benefits_list`**: coluna `text`, ainda validada e lida pelo portal (quebrada por `\n`), mas o CMS não a edita — só a API grava (`InstituicaoCmsController.php:122`; `AssociadosPortalController.php:129-133`).
- **`sort_order` e `is_active`**: existem como hidden sem controle visível; não há como ordenar nem ativar/desativar pela tela.
- **SEO**: nenhuma tela oferece campos de SEO, embora `bindModel` leia `metadata['seo']` e o seeder grave `seo.title` (`DemoContentSeeder.php:897-899`).

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `AssociadoInstituicao` | model | `extends Model implements RegionalOwnable`, `SoftDeletes`; `scopeActive`, `scopeOrdered`, `scopeByUnidadeRegional($q,$id)`, `belongsToUnidadeRegional($id):bool`, `getUnidadeRegionalId():?string`, `syncTagsFromArray(array)`, `syncTagsFromString(string)`, `syncToSearch()` | tabelas migradas; `v2_uuid()` carregado | controllers, seeder, API regional |
| `AssociadoInstituicaoGallery` | model | `instituicao()`, `galleryMedia()` | `gallery_media_id` pode ser nulo | controller, portal |
| `AssociadoTag` | model | `associados()` (pivô `v2_associado_tag_pivot`) | — | controller, portal |
| `InstituicaoCmsController` | controller | `index`, `archived`, `restore`, `create`, `store`, `edit`, `update`, `destroy`, `preview`, `uploadGallery`, `syncGallery`, `destroyGallery`, `saveBackgroundImage` | sessão Sentinel + RBAC `Associados` | rotas CMS |
| `AssociadosPortalController` | controller | `index()`, `detalhe($id)`, `show($slug)` | nenhuma | rotas web |
| `ModuleProvider` | provider | `getConfig(): array` | — | sidebar/composer do CMS |

### 4.2 Contrato de UI/JS

- Views CMS: `v2-associados::cms.instituicoes.{index,create,edit,preview}`; o form compartilhado é `_form.blade.php`.
- Views portal: `v2-associados::portal.{instituicoes,_card,instituicao,detalhe}`.
- O form usa `v2-service-media::components.media-input` (`gallery_logo_id`, `gallery_cover_image_id`) e escuta `media-selected.lotep` para sincronizar `logo_id`/`cover_image_id` (`_form.blade.php:220-251,358-373`).
- A galeria usa `v2-service-media::components.media-group-input` com `name = convenios_gallery`; o componente publica `convenios_gallery_ids[]` (sufixo `_ids`) (`edit.blade.php:159-166`; `V2/Services/Media/Views/components/media-group-input.blade.php:20,40-44`).
- O editor detalhado usa `v2-service-contentricheditor::components.forms.content-rich-editor` com `name = convenio_description` e valor inicial `{"type":"doc","content":[]}` (`create.blade.php:39-45`; `edit.blade.php:116-122`).
- O portal carrega o fragmento de detalhe por `XMLHttpRequest` sem header AJAX e injeta HTML em `#associadoModalBody` (`instituicoes.blade.php:243-318`).

### 4.3 Ordem e ciclo de vida

1. Provider registra migrations e views antes das rotas.
2. `module.active:Associados` resolve a flag `associados`; ausência de linha = ativo (fail-open) (`V2/Core/Services/ModuleFlagService.php:72-95`).
3. Só depois `CmsAuthenticate`/RBAC.
4. Escrita dispara, nesta ordem: `saving` → `creating` → `saved` (busca) → efeitos do controller (tags, regional, galeria) → cache → auditoria.
5. `deleted` remove do índice; `restore` não reindexa.

### 4.4 Receitas de uso

**(a) Criar uma instituição canonicamente pelo domínio**

```php
$inst = new AssociadoInstituicao();
$inst->name = 'Instituto Exemplo';
$inst->description = 'Convênio de exemplo';
$inst->benefits = [['name' => '10% de desconto', 'link' => null]];
$inst->social_media = [['network' => 'linkedin', 'icon' => 'bi-linkedin', 'url' => 'https://...', 'order' => 1, 'active' => true]];
$inst->save();                       // slug + índice público
$inst->syncTagsFromArray(['Educação']); // tag primária = Educação
```

Resultado: linha em `v2_associado_instituicoes`, tag criada/reutilizada, pivô com `is_primary=1`, registro no índice público.

**(b) Consultar o que o portal verá**

```php
$instituicoes = AssociadoInstituicao::active()->ordered()->paginate(12);
$comTag = AssociadoInstituicao::active()
    ->whereHas('tags', fn ($q) => $q->whereIn('v2_associado_tags.id', [$tagId]))
    ->get();
```

**(c) Renderizar o card e o modal**

```blade
@include('v2-associados::portal._card', ['instituicao' => $instituicao])
<div class="modal-body" id="associadoModalBody"></div>
{{-- GET /associados/detalhe/{id} devolve o fragmento sem layout --}}
```

**(d) Consumir a API regional que cria associados (token da regional)**

```http
POST /api/v2/unidaderegional/associados
Authorization: Bearer asc_<60 chars>
Content-Type: application/json

{"name":"Empresa X","description":"Markdown","url":"https://x.com","tags":"Tecnologia,Saúde"}
```

Efeito: instituição criada com `metadata.source='api'`, vínculo `active` com a regional autenticada e tags sincronizadas (`V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:392-416`; `Requests/ApiEmpresaRequest.php:23-45`).

### 4.5 Anti-uso

- **Usar `ConexaoTag` como catálogo de Associados**: o seletor e o portal filtram por `v2_associado_tags.id`; tags do Core não aparecem e os filtros retornam vazio (`AssociadosPortalController.php:56-58`).
- **Apagar a linha da galeria esperando apagar o arquivo**: `destroyGallery` só remove o vínculo; o arquivo e o registro em `v2_gallery_media` continuam (`InstituicaoCmsController.php:481-503`).
- **Confiar em `restore()` para voltar ao ar**: ele não reativa nem reindexa; é preciso um update com `is_active=1` (que a UI não oferece).

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

- Paginação CMS fixa em 15; portal 12 apenas sem filtros.
- Upload de galeria: 4 MB por arquivo, até o limite de `post_max_size` do PHP; array sem limite explícito de quantidade.
- `syncGallery` e `uploadGallery` fazem uma query de `count` por item.
- Cache de página: 5 min no Redis, invalidado por varredura; `Cache-Control: public, max-age=60, must-revalidate` na resposta HTTP (`V2/Core/Middleware/PublicCacheMiddleware.php:31-36,80-96`).
- API regional: `throttle.api:30,1` (30 req/min por prefixo de token) e `throttle:100,1` (`V2/Modules/Conexoes/UnidadeRegional/Routes/api.php:18-24`).

### 4.7 Matriz de integrações

| Serviço | Uso neste módulo | Contrato/limite | Fonte |
|---|---|---|---|
| Gallery | `GalleryService::upload($file, new GalleryConfig('associados', ['image/jpeg','image/png','image/webp'], 4096, false))`; leitura de `GalleryMedia::find($id)` | retorno é array da linha (`id` int, `file_path`, `variants`); módulo copia path; não usa o picker compartilhado nas views (usa `media-input`) | `InstituicaoCmsController.php:328-339,398-438`; `V2/Services/Gallery/DTOs/GalleryConfig.php:31-37` |
| Media | `media-input` (logo/capa) e `media-group-input` (galeria) | `media-input` envia `<name>` e `<name>_alt` e emite `media-selected.lotep`; `media-group-input` envia `<name>_ids[]` e sobe via `/cms/v2/gallery` | `V2/Services/Media/Views/components/media-input.blade.php:29-47`; `media-group-input.blade.php:20-44` |
| ContentRichEditor | `ContentRichEditorRendererService::toHtml(json_decode($convenio_description))` no preview e no portal | só renderiza se o JSON decodificado tiver `type`; caso contrário, HTML vazio | `InstituicaoCmsController.php:299-311`; `AssociadosPortalController.php:137-145,181-189` |
| SEO | `SEOService::setTitle/setDescription/setKeywords/bindModel` | `bindModel` lê `metadata.seo` (`title`, `description`, `keywords`, `image_url`, `json_ld`) e sobrescreve o que veio do controller | `AssociadosPortalController.php:92-94,167-170`; `V2/Services/SEO/Services/SEOService.php:29-58` |
| Search | `PublicIndexerService::index/remove` via hook `saved`/`deleted` | UPSERT por `(entity_type, entity_id)`; falhas são logadas e não bloqueiam o save | `AssociadoInstituicao.php:204-218,254-300`; `V2/Services/Search/Services/PublicIndexerService.php:21-72` |
| ActivityLogger | `log($model, $description, $properties)` | grava em `activity_log` com `performedOn`, `causedBy` e `v2_audit=true` | `InstituicaoCmsController.php:96-99,167-170,255-258,281-284,344-348,474-478,496-500`; `ActivityLoggerService.php:20-42` |
| Storage | `v2_storage_url()` nas views e `StorageService::url()` | caminho relativo ao disco `media`; URLs públicas | `V2/Core/Helpers/helpers.php:259-267`; views do portal |
| UnidadeRegional | pivot `v2_unidade_regional_empresas`; API cria/edita associados | token Bearer da regional; `status` `active`/`inactive` | `InstituicaoCmsController.php:374-396`; `UnidadeRegionalEmpresaApiController.php:345-370` |
| SupportConfigs | `PortalConfigsService::save('theme_associados_bg', path)` | upsert em `v2_portal_configs` + auditoria | `InstituicaoCmsController.php:518-521`; `PortalConfigsService.php:101-129` |

## 5. Rotas e contratos HTTP

Resumo (detalhe em [rotas.md](rotas.md)):

| Tipo | Método | URI | Nome | Middleware | Controller@ação |
|---|---|---|---|---|---|
| CMS | GET | `/cms/associados/instituicoes` | `v2.associados.cms.instituicoes.index` | `web`, `module.active:Associados`, `CmsAuthenticate` | `InstituicaoCmsController@index` |
| CMS | GET | `/cms/associados/instituicoes/arquivadas` | `...archived` | + `RegionalRestrictMiddleware` | `@archived` |
| CMS | POST | `/cms/associados/instituicoes/{id}/restaurar` | `...restore` | + `RegionalRestrictMiddleware` | `@restore` |
| CMS | GET/POST | `/cms/associados/instituicoes/nova` | `...create`/`...store` | `web`, `module.active`, `CmsAuthenticate` | `@create`/`@store` |
| CMS | GET | `/cms/associados/instituicoes/editar/{id}` | `...edit` | idem | `@edit` |
| CMS | PUT/POST | `/cms/associados/instituicoes/editar/{id}` | `...update` | idem | `@update` |
| CMS | GET | `/cms/associados/instituicoes/preview/{id}` | `...preview` | idem | `@preview` |
| CMS | DELETE | `/cms/associados/instituicoes/{id}` | `...destroy` | idem | `@destroy` |
| CMS | POST | `/cms/associados/instituicoes/{id}/gallery` | `...gallery.upload` | idem | `@uploadGallery` |
| CMS | POST | `/cms/associados/instituicoes/{id}/gallery/sync` | `...gallery.sync` | idem | `@syncGallery` |
| CMS | DELETE | `/cms/associados/gallery/{galleryId}` | `...gallery.destroy` | idem | `@destroyGallery` |
| CMS | POST | `/cms/associados/salvar-imagem-fundo` | `...save-bg` | + `RegionalRestrictMiddleware` | `@saveBackgroundImage` |
| Portal | GET | `/associados` | `v2.associados.portal.index` | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | `AssociadosPortalController@index` |
| Portal | GET | `/associados/detalhe/{id}` | `v2.associados.portal.detalhe` | idem | `@detalhe` |
| Portal | GET | `/associado/{slug}` | `v2.associados.portal.show` | idem | `@show` |

## 6. Dados e modelo de domínio

Detalhe em [dados.md](dados.md). Núcleo:

```mermaid
erDiagram
  v2_associado_instituicoes ||--o{ v2_associado_instituicao_gallery : gallery
  v2_associado_instituicoes ||--o{ v2_associado_tag_pivot : tags
  v2_associado_tags ||--o{ v2_associado_tag_pivot : tag
  v2_associado_instituicoes ||--o{ v2_unidade_regional_empresas : empresa
  v2_unidade_regionais ||--o{ v2_unidade_regional_empresas : unidade
  v2_gallery_media ||--o{ v2_associado_instituicao_gallery : media
```

- PK UUID em todas as tabelas do módulo, geradas em PHP (`v2_uuid`), sem default de banco.
- `v2_associado_instituicoes` tem soft delete e índice em `is_active`/`sort_order`; `slug` é único global.
- `v2_associado_tag_pivot` tem PK composta e `is_primary`; sem FKs declaradas.
- `v2_associado_instituicao_gallery` tem FK para a instituição com `onDelete('cascade')` e `gallery_media_id` varchar (não FK).
- `type` e `api_token` **não existem** em instituições (migrations no-op).

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Ativa: create (is_active=true)
  Ativa --> Arquivada: destroy (is_active=false + deleted_at)
  Arquivada --> Arquivada: restore (soft delete desfeito, is_active continua false)
  Arquivada --> Ativa: update com is_active=1 (não há UI)
  Ativa --> Ativa: update
```

- `FL-ASSOCIADOS-01` — create: valida → UUID → escalares → metadata → logo/capa → save → tags → regional → cache → auditoria → redirect.
- `FL-ASSOCIADOS-02` — update: authorize → valida → escalares → metadata → save → tags → regional → galeria → cache → auditoria → redirect para a edição.
- `FL-ASSOCIADOS-03` — arquivar/restaurar: descrito em §2.2; efeito público é imediato no arquivamento (índice removido) e ausente na restauração.
- `FL-ASSOCIADOS-04` — galeria: upload direto (arquivos) ou sync por IDs; ambos criam linhas; delete remove só a linha.
- `FL-ASSOCIADOS-05` — portal: GET lista (cache) → card → XHR do fragmento → modal; página por slug renderiza layout completo com SEO.

## 8. UI CMS

Detalhe em [cms.md](cms.md). Quatro telas: listagem (ativa + arquivada no mesmo blade), criação, edição e preview. Campos, validações, mensagens, modais e JS estão lá; em resumo: nome, tags (picker textual), unidade regional (travada para operador), site, redes sociais (builder JSON), badge de desconto, benefícios nome+link, descrição rápida, editor Tiptap, logo, capa, galeria e dois modais (tags do Core e imagem de fundo).

## 9. UI Portal

Detalhe em [portal.md](portal.md). Lista `/associados` com hero configurável, busca, checkboxes de tags, select de regional e contagem de resultados; com filtro, agrupa por tag primária e não pagina; sem filtro, 12 por página. Card abre modal com detalhe (logo, selo da regional, badge, descrição, conteúdo Tiptap, benefícios, galeria, site, redes e tags). `/associado/{slug}` repete o conteúdo em página completa com JSON-LD `Product`.

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

| Chave | Default | Efeito | Fonte |
|---|---|---|---|
| `v2.associados.api_token_ttl_days` | `365` (`V2_ASSOCIADOS_API_TOKEN_TTL`) | **órfã**: nenhum código lê; o TTL efetivo é o da regional | `config/v2.php:17-19` |
| `v2.unidade_regional.api_token_ttl_days` | inexistente → `365` | define a expiração do token da regional que cria associados | `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:281-287` |
| `cache.prefix` | `laravel` | prefixo do padrão `page:*` apagado na invalidação | `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:576` |
| `v2.support.email` | `suporte@maxima.inf.br` | isenta o suporte do escopo regional e do RBAC | `V2/Core/Services/RegionalScopeService.php:169-180`; `V2/Core/Helpers/helpers.php:12-27` |
| `v2.regional_operator.role_slug` | `regional` | slug do papel que recebe `Associados: write` | `V2/Core/Migrations/2026_09_14_000002_seed_v2_regional_role.php:21-26` |

`ModuleProvider::getConfig()`: título `Associados Regionais`, descrição `Gerenciamento de instituições associadas regionais e convênios.`, ícone `bi bi-award`, posição `5`, `active_pattern => ['cms/associados*']`, rota `v2.associados.cms.instituicoes.index`, submenu `Instituições Convênio` (`V2/Modules/Conexoes/Associados/ModuleProvider.php:14-32`).

Flags: a rota CMS usa `module.active:Associados`; a flag é procurada como `Associados`, `associados` ou `associados` (snake); sem linha, o módulo é considerado ativo (`V2/Core/Providers/V2ServiceProvider.php:303-306`; `V2/Core/Services/ModuleFlagService.php:72-95`).

Permissões: chave de módulo `Associados` com `read`/`write`; `read` só passa GET/HEAD; o papel `regional` recebe `write`; a blade esconde ações de arquivar/restaurar sem `write` (`V2/Core/Middleware/V2PermissionMiddleware.php:101-128`; `V2/Modules/Conexoes/Associados/Views/cms/instituicoes/index.blade.php:145-172`).

## 11. Observabilidade, erros e limites

- **Logs**: `Log::info('[InstituicaoCms] store|update|destroy', auditContext)` com `module`, `action`, `id`, `name`, `unidade_regional_id`, `user`, `ts`, `duration_ms`; `Log::warning` quando o cache falha; `Log::error` quando o renderer falha no preview (`InstituicaoCmsController.php:172,260,286,535-556`; `:307`).
- **Auditoria**: `activity_log` com descrições `created`, `updated`, `deleted`, `restored`, `gallery_uploaded`, `gallery_synced`, `deleted` (galeria), e propriedades `type` + dados (`ActivityLoggerService.php:20-42`).
- **HTTP**: `403` regional/RBAC, `404` em `findOrFail`/`firstOrFail`, `419` CSRF (grupo `web`), `422` nas validações.
- **Limites**: upload 4 MB; paginação 15/12; `keys` do Redis sobre todo o namespace; sem transação em create/update/galeria.
- **Dados sensíveis**: nenhum token é exposto por este módulo; a senha do suporte não transita aqui.

## 12. Seeds e dados iniciais

- `ResetTagsToDefaultCategories` (migration `2024_01_01_000019`): trunca pivô e catálogo e insere `Tecnologia`, `Dados`, `Inovação` (`Migrations/2024_01_01_000019_reset_tags_to_default_categories.php:21-44`). É destrutiva e não idempotente no efeito (apaga vínculos existentes).
- `DemoContentSeeder::seedAssociados()`: se já existir qualquer instituição, pula; cria 10 instituições com benefícios/redes em `metadata`, tag única por instituição e vínculo `active` com a regional do estado (`V2/Seeds/Content/DemoContentSeeder.php:753-916`).
- O seeder grava `metadata.seo.title` e deixa `benefits_list = null` (`DemoContentSeeder.php:880-900`).

## 13. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| `LG-ASSOCIADOS-01` | Não há UI nem rota para excluir/renomear `AssociadoTag`; tags órfãs acumulam. | Catálogo cresce sem curadoria; filtros ficam poluídos. | busca por `AssociadoTag` fora do módulo (nenhuma ocorrência) |
| `LG-ASSOCIADOS-02` | Não foi possível determinar se o modal duplicado na listagem é intencional (histórico de merge). | Duplicação de DOM e query; comportamento do segundo modal é inerte. | `git log` não disponível no corpus |
| `LG-ASSOCIADOS-03` | Não há dados sobre volume real de instituições/tags nem cardinalidade do pivô. | Dimensionamento de cache/índices. | produção/banco |
| `LG-ASSOCIADOS-04` | O fluxo de reativação após restauração não existe na UI. | Instituição restaurada fica permanentemente oculta. | decisão de produto |
| `LG-ASSOCIADOS-05` | O comportamento do `preview` quando `convenio_description` é Markdown (não Tiptap) é indefinido. | Conteúdo importado por API antiga pode não renderizar. | `ContentRichEditorRendererService::toHtml` |

## 14. Checklist de paridade

- [ ] `PC-ASSOCIADOS-01` — criar instituição com nome, 1 tag e regional gera linha ativa, slug único, tag primária e vínculo regional; redireciona ao index com modal de criação. Fonte: `InstituicaoCmsController.php:114-181`.
- [ ] `PC-ASSOCIADOS-02` — renomear **não** altera o slug; ele só é recalculado se estiver vazio. Fonte: `AssociadoInstituicao.php:186-202`.
- [ ] `PC-ASSOCIADOS-03` — arquivar desativa, faz soft delete, remove do índice e limpa `page:*`. Fonte: `:267-291`.
- [ ] `PC-ASSOCIADOS-04` — restaurar não reativa e não reindexa; portal continua 404. Fonte: `:89-104`.
- [ ] `PC-ASSOCIADOS-05` — operador regional só lista a própria regional e recebe 403 em registro alheio. Fonte: `RegionalScopeService.php:78-142`.
- [ ] `PC-ASSOCIADOS-06` — operador regional é bloqueado em arquivadas/restauração/fundo com a mensagem nacional-only. Fonte: `RegionalRestrictMiddleware.php:36-50`.
- [ ] `PC-ASSOCIADOS-07` — "Gerenciar Tags" cria em `v2_conexao_tags` sem alterar o seletor de Associados. Fonte: `index.blade.php:38-49`; `ConexaoTagCmsController.php:37-54`.
- [ ] `PC-ASSOCIADOS-08` — lista pública sem filtros pagina 12; com filtro retorna todos e agrupa pela tag primária. Fonte: `AssociadosPortalController.php:76-86`; `instituicoes.blade.php:155-193`.
- [ ] `PC-ASSOCIADOS-09` — tag inválida no query string é ignorada sem erro. Fonte: `:34-44`.
- [ ] `PC-ASSOCIADOS-10` — sync de galeria apaga somente linhas com `gallery_media_id` fora da lista. Fonte: `:440-472`.
- [ ] `PC-ASSOCIADOS-11` — upload aceita jpeg/png/jpg/webp até 4096 KB e rejeita outros. Fonte: `:323-328`.
- [ ] `PC-ASSOCIADOS-12` — página por slug renderiza JSON-LD `Product`; listagem renderiza `CollectionPage`. Fonte: `instituicao.blade.php:34-52`; `instituicoes.blade.php:14-30`.
- [ ] `PC-ASSOCIADOS-13` — modal de detalhe é servido como fragmento e entra no cache de página por 5 min. Fonte: `instituicoes.blade.php:281-320`; `PageCacheMiddleware.php:25-90`.
- [ ] `PC-ASSOCIADOS-14` — token de API é da regional (SHA-256, prefixo 12, TTL 365) e habilita `/api/v2/unidaderegional/associados`. Fonte: `UnidadeRegional.php:277-300`; `UnidadeRegionalEmpresaApiController.php:345-370`.

## 15. Evidências consultadas

- `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:1-586`
- `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:1-198`
- `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:1-357`
- `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicaoGallery.php:1-78`
- `V2/Modules/Conexoes/Associados/Models/AssociadoTag.php:1-43`
- `V2/Modules/Conexoes/Associados/ModuleProvider.php:1-34`
- `V2/Modules/Conexoes/Associados/Routes/cms.php:1-77`; `Routes/web.php:1-28`
- `V2/Modules/Conexoes/Associados/Migrations/2024_01_01_000004..000020`
- `V2/Modules/Conexoes/Associados/Views/cms/instituicoes/{_form,create,edit,index,preview}.blade.php`
- `V2/Modules/Conexoes/Associados/Views/portal/{_card,detalhe,instituicao,instituicoes}.blade.php`
- `V2/Core/Services/RegionalScopeService.php:1-235`; `V2/Core/Middleware/RegionalRestrictMiddleware.php:1-85`
- `V2/Core/Middleware/{CmsAuthenticate,V2PermissionMiddleware,PageCacheMiddleware,PublicCacheMiddleware,ModuleAccessMiddleware}.php`
- `V2/Core/Providers/V2ServiceProvider.php:80-320`; `V2/Core/Services/ModuleFlagService.php:60-127`
- `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:18-66`; `2026_08_18_000002_migrate_associado_tags_to_conexao_tags.php:15-48`
- `V2/Core/Controllers/ConexaoTagCmsController.php:21-99`; `V2/layouts/cms/partials/conexao-tags-modal.blade.php:1-203`
- `V2/Services/Media/Views/components/{media-input,media-group-input}.blade.php`; `V2/Services/Gallery/DTOs/GalleryConfig.php:31-37`; `V2/Services/Gallery/Services/GalleryService.php` (upload)
- `V2/Services/Search/Services/PublicIndexerService.php:21-72`; `V2/Services/Search/DTOs/PublicSearchDTO.php:8-39`
- `V2/Services/SEO/Services/SEOService.php:29-58,293-401`; `V2/layouts/portal/metadata/seo.blade.php:1-50`
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php:17-47`; `Models/UnidadeRegional.php:275-305`; `Routes/api.php:18-31`
- `V2/Seeds/Content/DemoContentSeeder.php:753-916`; `config/v2.php:12-50`
- `01_arquitetura/07-controllers-core.md:350-420` (achado de tags do CoreCms)
