---
id: MOD-CONEXOES-UNIDADEREGIONAL
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-CONEXOES-UNIDADEREGIONAL]
fontes:
  - V2/Modules/Conexoes/UnidadeRegional/
depende_de: [CORE-BOOT, CORE-CMS, CORE-AUTH, CORE-DATA, SVC-GALLERY, SVC-MEDIA, SVC-SEO, SVC-SEARCH, SVC-CONTENTRICHEDITOR, MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS]
consumidores: [MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS, SVC-SEO, SVC-SEARCH, UI-CMS, UI-PORTAL, GRP-CONEXOES]
criticidade: alta
dono: Regional
---

# Conexoes/UnidadeRegional

> **Escopo deste documento:** a unidade administrativa (CRUD CMS, modelo de dados, token de
> API, identidade regional e efeitos sobre busca/categoria). Os contratos detalhados estão
> nos documentos irmãos: [`dados.md`](dados.md), [`rotas.md`](rotas.md), [`api.md`](api.md),
> [`cms.md`](cms.md), [`portal.md`](portal.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Cadastra/ativa/arquiva unidades regionais da SUCESU, mantém a subcategoria de notícias `regional-<sigla>` sincronizada e emite tokens Bearer por unidade para que cada regional publique notícias e vincule associados na base nacional. |
| Entrada | Rotas CMS `cms/unidades-regionais*` (sessão Sentinel + RBAC), rotas públicas `GET /regionais` e `GET /regional/{slug}`, API `api/v2/unidaderegional/*` (Bearer `asc_...`) e `GET /api/v2/unidaderegional/openapi.json`. |
| Saída | Escritas em `v2_unidade_regionais`, `v2_unidade_regional_empresas`, `v2_unidade_regional_media` e `v2_unidade_regional_tag_pivot`; subcategoria em `v2_news_categories`; entradas no índice público de busca; JSON da API; HTML do CMS e do portal. |
| Efeitos colaterais | `saved` do model reindexa a busca pública e chama `RegionalIdentityService::sync()`; toda requisição autenticada da API regrava `api_token_last_used_at` (e portanto reexecuta os hooks `saved`); escritas limpam o page cache do Redis (`page:*`); `ActivityLoggerService` grava `created/updated/deleted/api_token_generated/...`. |
| Particularidade central | A **categoria da notícia é derivada no servidor** a partir da unidade autenticada (nunca aceita do payload) e o vínculo da notícia com a regional é mantido em **dois lugares** (`v2_news.unidade_regional_id` e `v2_news.metadata->source_unidade_regional_id`), com consultas diferentes em cada canal. |
| Como se integra | Namespace de views `v2-unidaderegional`; prefixo de API `/api/v2/unidaderegional` montado por `V2ServiceProvider::mapModuleRoutes()`; `ModuleProvider::getConfig()` entra no container `v2.modules.configs`; identificadores regionais via `V2\Core\Services\RegionalIdentity\RegionalIdentityService`; escopo de operador via `V2\Core\Services\RegionalScopeService`. |
| Se removido, quebra | Notícias perde a fonte da categoria regional (`RegionalIdentityService` referencia o model), o portal perde `/regionais` e `/regional/{slug}`, o sitemap perde as URLs de regionais, a busca perde o módulo `unidades-regionais`, e as APIs de notícias/associados por regional deixam de existir (tokens órfãos). |
| Superfície pública | `UnidadeRegional` (model, token, hooks), `UnidadeRegionalEmpresa`, `UnidadeRegionalMedia`, `UnidadeRegionalCmsController`, `UnidadeRegionalNewsApiController`, `UnidadeRegionalEmpresaApiController`, `UnidadeRegionalApiController@openapi`, `UnidadeRegionalPortalController`, `ApiNewsRequest`, `ApiEmpresaRequest`, `ModuleProvider`. |

## 1. Efeito

### 1.1 O que cada perfil consegue fazer

| Perfil | Ação | Resultado observável |
|---|---|---|
| Staff nacional com RBAC `UnidadeRegional: write` (ou e-mail `config('v2.support.email')`) | abrir `GET /cms/unidades-regionais` | lista paginada (15) com busca por nome/descrição/sigla e filtro de UF; badge de token por linha (`getTokenStatusBadge()`) |
| Operador regional (`users.unidade_regional_id`) | abrir o módulo | **bloqueado**: o perfil `regional` semeado só concede `Noticias` e `Associados`; o módulo é key `UnidadeRegional` (`V2/Core/Migrations/2026_09_14_000002_seed_v2_regional_role.php:18-21`) |
| Staff nacional | criar/editar unidade | grava a unidade, sincroniza tags, galeria, logo/capa (via `metadata`), e dispara reindexação + criação/renomeação de `regional-<sigla>` |
| Staff nacional | gerar token | `POST /cms/unidades-regionais/{id}/api-token/gerar` devolve `raw_token` (exibido uma única vez), `prefix` e `expires_at` |
| Staff nacional | revogar token | `POST .../api-token/revogar` zera hash/prefixo/expiração; requisições seguintes com o token antigo recebem `401 invalid_token` |
| Staff nacional | abrir API Docs | `GET /cms/unidades-regionais/documentacao` renderiza guia, tabela de rate limit, 6 exemplos de cliente e Swagger UI sob demanda |
| Portador de token `asc_...` | `GET/POST/DELETE` em `/api/v2/unidaderegional/{noticias,associados}` | notícias estritamente da própria unidade (`pending_approval`); vínculos de associado são recortados pela unidade, mas `empresa_ids` aceita qualquer associado nacional não arquivado (SEC-G4-03; `api.md` §3.6.2) |
| Qualquer visitante | `GET /regionais` | lista as unidades `is_active = true`, ordenadas por `sort_order, name`, 12 por página |
| Qualquer visitante | `GET /regional/{slug}` | página da unidade ativa com descrição rica, informações, galeria, associados e até 12 notícias publicadas; unidade inativa/inexistente → 404 |
| Visitante | `GET /api/v2/unidaderegional/openapi.json` | spec OpenAPI 3.0.3 com `Cache-Control: public, max-age=3600` |

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| Linha em `v2_unidade_regionais` com UUID v4, `slug` único derivado do nome, `acronym`, `state`, `metadata` (resumo, redes sociais, logo/capa) e colunas de token | `UnidadeRegionalCmsController@store` + hooks do model | `Controllers/UnidadeRegionalCmsController.php:100-171`; `Models/UnidadeRegional.php:100-129` |
| Subcategoria `regional-<sigla>` (nome `Regional <SIGLA>`) sob `regionais`, criada/renomeada a cada `saved` | `RegionalIdentityService::newsCategoryFor()` via hook `saved` | `Models/UnidadeRegional.php:131-145`; `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:33-61` |
| Entrada/atualização no índice público (`module = unidades-regionais`) ou remoção quando `is_active = false` | `syncToSearch()` no hook `saved` e `deleted` | `Models/UnidadeRegional.php:156-188` |
| Linha em `v2_unidade_regional_tag_pivot` por tag do formulário; a primeira tag recebe `is_primary = true` | `syncTagsFromArray()` | `Models/UnidadeRegional.php:82-98` |
| Linha em `v2_unidade_regional_media` por mídia da galeria, com `sort_order = count + 1` | `syncGalleryMediaIds()` | `Controllers/UnidadeRegionalCmsController.php:397-435` |
| Token: `api_token_hash` (sha256), `api_token_prefix` (`asc_` + 8), `api_token_expires_at`, `api_token_created_at` | `UnidadeRegional::generateApiToken()` | `Models/UnidadeRegional.php:277-291` |
| `api_token_last_used_at = now()` em **toda** requisição autenticada da API | `authenticateToken()` | `Controllers/UnidadeRegionalNewsApiController.php:466-467`; `Controllers/UnidadeRegionalEmpresaApiController.php:366-367` |
| Notícia criada pela API: `status = pending_approval`, `author_id = null`, `unidade_regional_id`, `metadata.source_*` e `metadata.source_markdown` | `createNoticia()` | `Controllers/UnidadeRegionalNewsApiController.php:209-249` |
| Tag real com o slug da sigla (`syncWithoutDetaching`) na notícia criada pela API | `assignAcronymTag()` | `Controllers/UnidadeRegionalNewsApiController.php:577-589` |
| Associado criado pela API com `metadata.category = 'regional'`, `is_active = true`, `sort_order = 0` | `createEmpresa()` | `Controllers/UnidadeRegionalEmpresaApiController.php:389-430` |
| Vínculo unidade↔associado em `v2_unidade_regional_empresas` (`status` `active|inactive`) | `linkEmpresa()` | `Controllers/UnidadeRegionalEmpresaApiController.php:468-496` |
| Arquivamento de associado **na base nacional** + remoção do vínculo | `destroy()` | `Controllers/UnidadeRegionalEmpresaApiController.php:194-225` |
| Page cache Redis `page:*` apagado em qualquer escrita (CMS e API) | `clearPageCache()` | `Controllers/UnidadeRegionalCmsController.php:524-536`; `Controllers/UnidadeRegionalNewsApiController.php:596-608`; `Controllers/UnidadeRegionalEmpresaApiController.php:516-528` |
| Config `theme_regionais_bg` em `v2_portal_configs` | `saveBackgroundImage()` via `PortalConfigsService::save()` | `Controllers/UnidadeRegionalCmsController.php:488-507` |
| Auditoria (`activities`) com `v2_audit = true`, IP e user agent | `ActivityLoggerService::log()` | `V2/Services/ActivityLogger/ActivityLoggerService.php:20-38` |

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

| Entrada | Saída |
|---|---|
| `GET /cms/unidades-regionais?search=df&state=DF` | `200` HTML; filtra `name/description/acronym ILIKE %df%` e `state = 'DF'` |
| `POST /cms/unidades-regionais/nova` sem `state` | `302` de volta com erro de validação (`state` `required|size:2`), sem escrita |
| `POST /cms/unidades-regionais/nova` válido | `302` para o índice com flash `Unidade Regional "<nome>" criada com sucesso!` |
| `DELETE /cms/unidades-regionais/{id}` | `302`; unidade vira `is_active = false`, token revogado e `deleted_at` preenchido |
| `POST /cms/unidades-regionais/{id}/api-token/gerar` | `200 {"ok":true,"data":{"raw_token":"asc_...","prefix":"asc_XXXXXXXX","expires_at":"..."},"message":"Token de API gerado com sucesso.","error":null}` |
| `POST /cms/unidades-regionais/{id}/api-token/revogar` | `200 {"ok":true,"data":null,"message":"Token revogado com sucesso."}` |
| API sem header `Authorization` | `401 {"ok":false,"error":{"type":"invalid_token",...,"title":"Token não fornecido"},"message":"Token de autenticação não fornecido."}` |
| API com token de outra unidade | `404 not_found` (notícias e vínculos) ou `403 forbidden` (associado existente não vinculado) |
| API `POST /noticias` válido | `201 {"ok":true,"data":{id,title,slug,status:"pending_approval",created_at},"message":"Notícia recebida e pendente de aprovação."}` |
| API `POST /noticias/{id}` sem `title` ou `content` | `422` no formato padrão do Laravel (`message` + `errors`), **não** no envelope `ApiResponse` |
| API `DELETE /noticias/{id}` já arquivada | `409 {"error":{"type":"already_archived","title":"archived"},"message":"Esta notícia já está arquivada."}` |
| 31ª `POST /noticias` em 1 minuto com o mesmo token | `429 {"error":"too_many_requests","message":"Limite de requisições excedido. Tente novamente em <n> segundos.","retry_after":<n>}` com `X-RateLimit-*` |
| `GET /regional/{slug}` de unidade inativa | `404` (`firstOrFail` sobre `active()`) |

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

- **Não aprova nem publica notícias**: escreve sempre `News::STATUS_PENDING_APPROVAL` (`Controllers/UnidadeRegionalNewsApiController.php:214`); a publicação é do CMS de Notícias.
- **Não aceita `category_id`**: o campo não existe em `ApiNewsRequest`; qualquer valor enviado é ignorado porque `assignRegionaisCategory()` sobrescreve (`:555-570`).
- **Não gerencia permissões nem usuários**: cria apenas a relação `operadores()` (`Models/UnidadeRegional.php:65-68`); o vínculo é escrito por Gestão de Permissões.
- **Não apaga fisicamente**: unidade, vínculo e mídia usam soft delete/`delete()` de model; nenhuma migration de `forceDelete`.
- **Não envia e-mail, feed ou notificação**: nenhum `Mail::`, `Notification` ou `FeedSender` no módulo.
- **Não valida unicidade de `acronym`**: só `slug` é `unique`; duas unidades podem ter a mesma sigla e disputar a mesma categoria `regional-<sigla>`.
- **Não tem paginação configurável no CMS**: `paginate(15)` fixo em index/archived/news; portal fixa 12.
- **Não expõe CRUD de associados**: apenas vínculo (`empresa_ids`) e criação mínima; edição completa é do módulo Associados.

## 2. Abordagem

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

O módulo é o **registro canônico da regional**: uma linha em `v2_unidade_regionais` é ao mesmo
tempo (a) entidade de portal, (b) dono de uma subcategoria de notícias, (c) credencial de API e
(d) alvo do escopo de operador regional. O acoplamento é feito por hooks do model, não por
serviço orquestrador: qualquer `save()` — inclusive o que só atualiza `last_used_at` — propaga
para busca e categoria.

```mermaid
flowchart LR
  subgraph CMS
    A[GET/POST cms/unidades-regionais] --> B[CmsAuthenticate]
    B --> C[V2PermissionMiddleware key=UnidadeRegional]
    C --> D[UnidadeRegionalCmsController]
  end
  subgraph API
    E[Bearer asc_...] --> F[authenticateToken sha256]
    F --> G[UnidadeRegionalNewsApiController]
    F --> H[UnidadeRegionalEmpresaApiController]
  end
  subgraph Portal
    I[/regionais] --> J[UnidadeRegionalPortalController]
    K[/regional/slug] --> J
  end
  D --> M[(v2_unidade_regionais)]
  G --> N[(v2_news)]
  H --> O[(v2_unidade_regional_empresas)]
  H --> P[(v2_associado_instituicoes)]
  M -->|saved hook| Q[RegionalIdentityService]
  Q --> R[(v2_news_categories)]
  M -->|saved/deleted hook| S[PublicIndexerService]
  D --> T[pack_cache page:*]
  G --> T
  H --> T
```

### 2.2 Mecânica passo a passo

1. **Boot** — `V2ServiceProvider` varre `V2/Modules/<Grupo>/<Modulo>`, instancia
   `ModuleProvider`, injeta `group = Conexoes` e guarda `$config` em `v2.modules.configs`
   (`V2/Core/Providers/V2ServiceProvider.php:95-133`). As views são carregadas no namespace
   `v2-unidaderegional` (`:120-123`) e as migrations do módulo são registradas (`:114-118`).
2. **Rotas** — CMS recebe `web` + `module.active:UnidadeRegional` (`:303-307`); web pública
   recebe `web` + `MaintenanceMiddleware` + `page.cache` + `public.cache` (`:297-301`); API
   recebe o prefixo `api/v2/unidaderegional` + grupo `api` (`:309-313`).
3. **Gate CMS** — `CmsAuthenticate` exige Sentinel e ao menos um papel, depois delega a
   `V2PermissionMiddleware` (`V2/Core/Middleware/CmsAuthenticate.php:16-44`), que casa
   `active_pattern = ['cms/unidades-regionais*']` com o módulo `UnidadeRegional`
   (`ModuleProvider.php:21`). Sem permissão → `403` para JSON ou redirect ao dashboard.
4. **Validação CMS** — `$this->validate()` inline no controller (não há FormRequest de CMS):
   `name`, `acronym`, `state` obrigatórios; `description`/`resumo`/`logo_id`/`site_url`/
   `cover_image_id` opcionais (`Controllers/UnidadeRegionalCmsController.php:100-119`).
5. **Persistência CMS** — o controller monta o model campo a campo, resolve logo/capa na
   galeria (`handleLogoFromGallery`/`handleCoverFromGallery`), grava `metadata` (resumo, redes
   sociais legadas e builder JSON) e chama `save()`; só depois sincroniza tags e galeria.
6. **Hooks do model** — `creating` gera UUID e slug; `saving` resolve colisão de slug com
   sufixo `-1`, `-2`…; `saved` reindexa a busca e sincroniza a categoria regional; `deleted`
   remove do índice (`Models/UnidadeRegional.php:100-153`).
7. **Token** — `generateApiToken()` sorteia `asc_` + 60 caracteres, guarda apenas o sha256 e o
   prefixo de 12 caracteres, define expiração e salva (`Models/UnidadeRegional.php:277-291`).
8. **API** — cada controller valida o Bearer por sha256, checa expiração, atualiza
   `last_used_at`, then aplica o recorte por unidade na consulta e na escrita
   (`Controllers/UnidadeRegionalNewsApiController.php:445-470`).
9. **Portal** — `index` lista `active()->ordered()` paginado em 12 e resolve a imagem de fundo
   `theme_regionais_bg`; `show` resolve por slug entre ativas, renderiza a descrição Tiptap,
   carrega galeria, até 12 notícias publicadas e os associados ativos, e serializa esses
   associados em JSON para filtro client-side (`Controllers/UnidadeRegionalPortalController.php:40-120`).

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

- **Categoria derivada no servidor** — `assignRegionaisCategory()` ignora o corpo do request e
  usa `RegionalIdentityService::newsCategoryFor($unidade)`; o OpenAPI declara explicitamente
  que `category_id` é ignorado (`Controllers/UnidadeRegionalApiController.php:35-37`). Evita
  que a regional publique em categoria nacional. `[C]`
- **Token por unidade, hash + prefixo** — permite exibir/listar sem guardar o segredo e
  revogar sem apagar a linha. `[C]` (`Models/UnidadeRegional.php:277-291`)
- **Vínculo duplo da notícia** — `unidade_regional_id` (FK) para consultas do CMS e
  `metadata->source_unidade_regional_id` para a API; `News::scopeByRegional` cobre os dois
  com `orWhere` (`V2/Modules/Publicacoes/Noticias/Models/News.php:439-445`). Foi uma solução
  de compatibilidade entre notícias criadas no CMS e pela API. `[I]`
- **Sincronização por hook de model** — reduz duplicação (qualquer caminho que salve a unidade
  atualiza categoria/busca), mas acopla efeitos caros a `save()` de rotina. `[C]`
- **Associados como vínculo, não como entidade própria** — a regional cria registros em
  `v2_associado_instituicoes` (tabela do módulo Associados) com `metadata.category='regional'`;
  não existe tabela de "associado regional". `[C]`
- **Galeria via `v2_gallery_media` + tabela espelho** — `v2_unidade_regional_media` guarda
  `image` (path) e `gallery_media_id` (inteiro), duplicando o path. `[C]`

### 2.4 Trechos de código pivô

```php
// V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:277-291 — formato e TTL do token
public function generateApiToken()
{
    $raw = 'asc_' . \Illuminate\Support\Str::random(60);

    $this->api_token_hash = hash('sha256', $raw);
    $this->api_token_prefix = substr($raw, 0, 12);
    $this->api_token_expires_at = now()->addDays(
        config('v2.unidade_regional.api_token_ttl_days', 365)
    );
    $this->api_token_last_used_at = null;
    $this->api_token_created_at = now();
    $this->save();

    return $raw;
}
```

O segredo bruto só existe nesta chamada; o banco recebe sha256 e os 12 primeiros caracteres
(`asc_` + 8). Como `config('v2.unidade_regional.*')` não existe em `config/v2.php`, o TTL é
**sempre 365 dias** (achado SEC-G1-14).

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:445-470 — autenticação Bearer
private function authenticateToken(Request $request)
{
    $token = $request->bearerToken();
    if (empty($token)) {
        return $this->error('Token não fornecido', 'Token de autenticação não fornecido.', 401, 'invalid_token');
    }

    $tokenHash = hash('sha256', $token);
    $unidade = UnidadeRegional::where('api_token_hash', $tokenHash)->first();

    if (!$unidade) {
        return $this->error('Token inválido', 'Token inválido ou unidade regional não encontrada.', 401, 'invalid_token');
    }

    if (!$unidade->isApiTokenValid()) {
        return $this->error('Token expirado',
            'Token de API expirado. Renove o token no CMS da SUCESU Nacional.',
            401, 'token_expired'
        );
    }

    $unidade->api_token_last_used_at = now();
    $unidade->save();

    return $unidade;
}
```

Dois detalhes de alto impacto: a busca é por hash exato (sem índice único, apenas índice
comum) e o `save()` dispara os hooks `saved` do model — ou seja, **cada request da API
reindexa a unidade na busca pública e reexecuta o sync da categoria**.

```php
// V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:131-153 — efeitos colaterais do save/delete
static::saved(function ($model) {
    try {
        $model->syncToSearch();
    } catch (\Exception $e) {
        \Log::error('[UnidadeRegional] Falha ao sincronizar busca: ' . $e->getMessage());
    }

    if (!empty($model->acronym)) {
        try {
            app(RegionalIdentityService::class)->sync($model);
        } catch (\Exception $e) {
            \Log::error('[UnidadeRegional] Falha ao prover identidade regional: ' . $e->getMessage());
        }
    }
});

static::deleted(function ($model) {
    try {
        app(PublicIndexerService::class)->remove(get_class($model), $model->id);
    } catch (\Exception $e) {
        \Log::error('[UnidadeRegional] Falha ao remover do índice público: ' . $e->getMessage());
    }
});
```

Os hooks são *fail-soft*: erro de busca/identidade é logado e a transação principal continua.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:555-589 — categoria e tag derivadas
protected function assignRegionaisCategory(News $news)
{
    if (empty($news->unidade_regional_id)) {
        return;
    }

    $unidade = UnidadeRegional::find($news->unidade_regional_id);
    if (!$unidade) {
        return;
    }

    $category = $this->identity->newsCategoryFor($unidade);
    if ($category) {
        $news->category_id = $category->id;
    }
}

protected function assignAcronymTag(News $news, UnidadeRegional $unidade)
{
    if (empty($unidade->acronym)) {
        return;
    }

    $tag = NewsTag::firstOrCreate(
        ['slug' => str_slug($unidade->acronym)],
        ['name' => $unidade->acronym, 'id' => v2_uuid()]
    );

    $news->tags()->syncWithoutDetaching([$tag->id]);
}
```

A categoria vem da unidade **relida do banco**, não do objeto autenticado. A sigla vira tag em
`v2_news_tags`/pivô de notícias (`NewsTag`), independentemente do array `tags` do payload — que
vai apenas para `metadata.tags`.

```php
// V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalCmsController.php:397-435 — sincronização da galeria
protected function syncGalleryMediaIds(UnidadeRegional $unidade, array $mediaIds)
{
    // Filtra apenas IDs inteiros positivos válidos (defense-in-depth contra type mismatch)
    $mediaIds = array_values(array_filter($mediaIds, function ($id) {
        return is_numeric($id) && (int) $id > 0;
    }));
    $mediaIds = array_map('intval', $mediaIds);

    UnidadeRegionalMedia::where('unidade_regional_id', $unidade->id)
        ->whereNotNull('gallery_media_id')
        ->whereNotIn('gallery_media_id', $mediaIds)
        ->delete();

    $existingIds = UnidadeRegionalMedia::where('unidade_regional_id', $unidade->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;

        $mediaItem = new UnidadeRegionalMedia();
        $mediaItem->id = v2_uuid();
        $mediaItem->unidade_regional_id = $unidade->id;
        $mediaItem->image = $galleryMedia->file_path;
        $mediaItem->gallery_media_id = $mediaId;
        $mediaItem->sort_order = UnidadeRegionalMedia::where('unidade_regional_id', $unidade->id)->count() + 1;
        $mediaItem->save();
    }
```

A sincronização é **destrutiva por diferença**: toda mídia com `gallery_media_id` fora da lista
enviada é apagada. Chamada a cada `update()` do CMS.

### 2.5 Algoritmos, consultas e custos

- `News::where('metadata->source_unidade_regional_id', $unidade->id)` — operador JSONB sem
  índice dedicado; a FK `unidade_regional_id` existe, mas a API não a usa no `GET`/`DELETE`
  (`Controllers/UnidadeRegionalNewsApiController.php:69,153,284`).
- `News::scopeByRegional` usa `orWhere` dentro de um `where(function …)`, portanto não pode ser
  combinado com `where` de outro escopo sem parênteses adicionais.
- `index` da API pagina com `per_page` livre (sem `max`), `orderByDesc('created_at')`
  (`:69-71`).
- `associados` da API carrega `with('empresa.tags')` e mapeia em memória (N+1 evitado);
  o portal usa `with(['media','empresas.empresa.tags'])` (`PortalController.php:48-50`).
- `syncToSearch` percorre recursivamente o JSON Tiptap para extrair texto
  (`Models/UnidadeRegional.php:196-232`).
- Resolução de slug em `saving` faz uma query por tentativa (loop `while exists`)
  (`Models/UnidadeRegional.php:113-129`).
- `clearPageCache` executa `KEYS page:*` + `DEL` — custo O(n) no Redis, com `try/catch` que só
  loga warning (`Controllers/UnidadeRegionalCmsController.php:524-536`).

## 3. Particularidades

### 3.1 O que só existe aqui

- **Fonte única da identidade regional**: `RegionalIdentityService` (Core) referencia este
  model e é o único lugar que deriva `regional-<sigla>`; `syncAll()` também parte de
  `UnidadeRegional::active()` (`V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:108-120`).
- **Token de API por unidade com prefixo exibível** — nenhum outro canal do V2 usa
  `asc_`/`api_token_prefix`; o canal interno usa `X-Access-Key` e tabela própria.
- **Dois modos de vínculo de notícia** (FK + metadata) convivem apenas aqui.
- **`metadata.social_media`** (builder JSON) com ordem fixa no portal (`instagram, linkedin,
  facebook, twitter, youtube, whatsapp, telegram`) — `PortalController.php:171-195`.
- **`theme_regionais_bg`** é a única configuração de portal gravada por este módulo
  (`Controllers/UnidadeRegionalCmsController.php:488-507`).

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-UNIDADEREGIONAL-01 | Toda notícia criada pela API nasce `pending_approval`, com `author_id = null` | Não aparece no portal até aprovação no CMS de Notícias | `Controllers/UnidadeRegionalNewsApiController.php:214-215` |
| RN-UNIDADEREGIONAL-02 | `category_id` do payload é ignorado; categoria = `regional-<strtolower(acronym)>` sob `regionais` | Regional não escolhe categoria | `Controllers/UnidadeRegionalNewsApiController.php:555-570`; `RegionalIdentityService.php:22-61` |
| RN-UNIDADEREGIONAL-03 | Leitura/escrita de notícias é escopada por `metadata->source_unidade_regional_id`; vínculos são escopados por `unidade_regional_id`, mas o **cadastro nacional** referenciado por `empresa_ids` não tem verificação de dono | Unidade A não vê notícias da B, mas pode reivindicar/editar/arquivar um associado nacional conhecido (SEC-G4-03) | `Controllers/UnidadeRegionalNewsApiController.php:69,153,284`; `Controllers/UnidadeRegionalEmpresaApiController.php:50-52` |
| RN-UNIDADEREGIONAL-04 | Token expirado continua na linha (badge "Expirado") mas falha com `401 token_expired` | Renovação explícita no CMS | `Models/UnidadeRegional.php:255-264`; `...NewsApiController.php:459-464` |
| RN-UNIDADEREGIONAL-05 | Token sem `api_token_expires_at` é considerado válido para sempre | `isApiTokenValid()` retorna `true` se a data é nula | `Models/UnidadeRegional.php:260-262` |
| RN-UNIDADEREGIONAL-06 | `slug` é único e ganha sufixo `-1`, `-2`… automaticamente ao colidir | URL do portal muda silenciosamente | `Models/UnidadeRegional.php:113-129`; migration `2026_07_23_000001:21` |
| RN-UNIDADEREGIONAL-07 | `saved` sempre reindexa a busca; `is_active=false` remove do índice | Unidade desativada some da busca | `Models/UnidadeRegional.php:158-165` |
| RN-UNIDADEREGIONAL-08 | TTL do token é fixo em 365 dias (config inexistente) | Impossível configurar por env | `Models/UnidadeRegional.php:283-285`; `config/v2.php:1-70` |
| RN-UNIDADEREGIONAL-09 | Atualizar notícia exige `title` e `content` (FormRequest único para create/update) | "Update parcial" documentado não funciona sem ambos | `Requests/ApiNewsRequest.php:24-39`; `Routes/api.php:11-13` |
| RN-UNIDADEREGIONAL-10 | `DELETE /associados/{id}` arquiva o associado **na base nacional** | Afeta todas as regionais que o compartilham | `Controllers/UnidadeRegionalEmpresaApiController.php:194-201` |
| RN-UNIDADEREGIONAL-11 | `POST /associados/{id}` espera UUID de **associado**; `DELETE /associados/{id}` espera UUID do **vínculo** | Mesma URI, identificadores diferentes | `Controllers/UnidadeRegionalEmpresaApiController.php:236-242,185-192` |
| RN-UNIDADEREGIONAL-12 | O CMS não pode limpar logo/capa enviando vazio | `handleLogoFromGallery`/`handleCoverFromGallery` só agem quando há id | `Controllers/UnidadeRegionalCmsController.php:361-396` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Sem `acronym`**: `newsCategorySlug()` devolve `null`; a notícia é criada **sem
  `category_id`** e sem tag. O formulário CMS exige a sigla, mas a API não a cria.
- **Sigla duplicada**: duas unidades com mesma sigla compartilham `regional-<sigla>`;
  `newsCategoryFor` renomeia a categoria para a última sigla salva (mesmo valor) e ambas
  publicam na mesma categoria.
- **Unidade arquivada**: `deleted` remove do índice; o portal 404; o token continua no banco
  até o `destroy()` revogar (o controller revoga antes do delete — `:281-285`).
- **Token inválido vs. unidade sem token**: ambos `401 invalid_token`; não há distinção.
- **`per_page` negativo/zero** na API: repassado direto ao `paginate()`.
- **Notícia criada no CMS para a regional** (FK preenchida, sem metadata): listada no CMS
  (`byRegional`), mas **invisível** para o `GET` da API e não arquivável por ela.
- **Atualização de notícia arquivada**: `409 archived_cannot_reactivate`.
- **Associado arquivado**: vínculos cujo `empresa` está soft-deleted aparecem em
  `GET /associados` com `nome/slug/logo = null` (a relação `empresa()` não usa `withTrashed`).
- **Upload de imagem por URL**: falha de download/tipo não aborta a notícia; só
  `Log::warning` e a notícia é criada sem capa (`:191-203`).
- **Falha de Redis**: `clearPageCache` loga e segue; a resposta é sucesso mesmo com cache velho.
- **`description` do associado** não tem limite de tamanho na validação (string aberta).

### 3.4 Armadilhas de reimplementação

1. **Não unificar o filtro de notícias em um só campo.** A API filtra por metadata; o CMS por
   FK **ou** metadata. Unificar muda o conjunto de registros visível e quebra a
   compatibilidade com notícias legadas.
2. **Não derivar a categoria a partir do request nem do objeto em memória**: o serviço relê a
   unidade (`UnidadeRegional::find`) e usa a sigla normalizada em minúsculas.
3. **Não mover o `last_used_at` para um `update()` silencioso** sem revisar os hooks: hoje todo
   request reindexa busca e categoria; reimplementar com `updateQuietly` remove esse efeito
   (provavelmente desejável, mas é mudança de comportamento observável no índice).
4. **Não tratar `POST /associados/{id}` e `DELETE /associados/{id}` como o mesmo recurso**:
   update usa `empresa_id`, delete usa `id` do vínculo.
5. **Não assumir que o CMS valida `acronym` único** nem que `logo_id` é UUID — a coluna de
   galeria é inteira.
6. **Não reaproveitar `getTokenStatusBadge()` como HTML confiável**: ele devolve string com
   `<span>`, renderizada com `{!! !!}` (`Views/cms/index.blade.php:123`; `Views/cms/edit.blade.php:173`).

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

| Divergência | Documento diz | Código faz | Fonte |
|---|---|---|---|
| TTL configurável | `MODULE.md` cita `config('v2.unidade_regional.api_token_ttl_days')` (default 365) | a chave não existe em `config/v2.php`; valor sempre 365 | `MODULE.md:39`; `config/v2.php:1-70`; `Models/UnidadeRegional.php:283-285` |
| Update parcial | `MODULE.md` e OpenAPI dizem "update parcial" | `ApiNewsRequest` exige `title` + `content` em qualquer POST | `MODULE.md:47`; `Controllers/UnidadeRegionalApiController.php:162-203`; `Requests/ApiNewsRequest.php:24-39` |
| Throttle | `MODULE.md` associa throttle a POST | correto, mas GET/DELETE ficam sem `throttle.api` (SEC-G1-14) | `Routes/api.php:4-6,15-16,26-30` |
| `category` do associado | API devolve `category` e o usa na busca | coluna removida do banco; valor sempre `null` | `Controllers/UnidadeRegionalEmpresaApiController.php:73,489-500`; `Controllers/UnidadeRegionalPortalController.php:126-140`; `V2/Modules/Conexoes/Associados/Migrations/2024_01_01_000020_drop_category_from_associado_instituicoes.php:17-37` |
| Docs no CMS | remete a `wiki/api/unidade-regional.md` | arquivo existe em `wiki/api/unidade-regional.md` (documentação paralela, não versionada no módulo) | `Views/cms/docs.blade.php:120` |
| Rotas de galeria | declaradas e implementadas | nenhuma view/JS as chama; a galeria real usa `v2.gallery.store` + `gallery_media_ids` no update | `Routes/cms.php:48-61`; `Views/cms/edit.blade.php:136-150` |

Dívidas observáveis: `app()` inline em `getConverter()` e nos hooks; `__construct()` vazio com
`$app = app()` em `ModuleProvider.php:9-12`; paginação fixa; `ilike` (PostgreSQL-only) no
controller CMS.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `UnidadeRegional` | model | `active()`, `ordered()`, `byState($uf)`, `hasApiToken()`, `isApiTokenValid()`, `isTokenExpired()`, `generateApiToken()`, `revokeApiToken()`, `getTokenExpiryForHumans()`, `getTokenStatusBadge()`, `syncTagsFromArray(array)` | tabela migrada | CMS, portal, SEO, Search, Noticias, Associados |
| `UnidadeRegionalEmpresa` | model | `unidadeRegional()`, `empresa()` | — | API de associados, CMS |
| `UnidadeRegionalMedia` | model | `unidadeRegional()`, `galleryMedia()` | — | portal, CMS |
| `RegionalIdentityService` (Core) | serviço | `newsCategoryFor(UnidadeRegional)`, `sync()`, `syncAll()`, `isRegionalSlug(string)` | — | API de notícias, Noticias CMS |
| `UnidadeRegionalCmsController` | controller | `index`, `archived`, `create`, `store`, `edit`, `update`, `destroy`, `restore`, `news`, `docs`, `uploadGallery`, `syncGallery`, `destroyGallery`, `generateApiToken`, `revokeApiToken`, `saveBackgroundImage` | sessão + RBAC | rotas CMS |
| `UnidadeRegionalPortalController` | controller | `index()`, `show($slug)` | — | rotas `web` |
| `ModuleProvider::getConfig()` | provider | `array` | — | Sidebar, RBAC, ModuleFlagService |

### 4.2 Contrato de UI/JS (quando aplicável)

- Views no namespace `v2-unidaderegional` (provider `V2/Modules/Conexoes/UnidadeRegional/Views`).
- `cms/_form` depende de `v2-service-media::components.media-input` (logo/capa) e do
  ContentRichEditor; o picker de tags usa `fetch('{{ route("v2.cms.conexao-tags.json") }}')`
  (`Views/cms/_form.blade.php:352-376`).
- `edit` espera `#confirmGenerateTokenModal`, `#confirmRevokeTokenModal`, `#apiTokenModal`,
  `#apiTokenRaw` (`api-token-raw`) e faz `fetch('/cms/unidades-regionais/{id}/api-token/gerar')`
  com `X-CSRF-TOKEN` lido de `input[name="_token"]` (`Views/cms/edit.blade.php:383-420`).
- Portal: `#ur-associados-data` (JSON), `#ur-associados-grid`, `#ur-associados-search`,
  `.js-associados-more`, `#associadoRegionalModal`, `#galleryModal`, `.gallery-thumb`.
- Docs: Swagger UI carregado de CDN `https://unpkg.com/swagger-ui-dist@5/...` sob demanda no
  evento `show.bs.collapse` (`Views/cms/docs.blade.php:1013-1035`).

### 4.3 Ordem e ciclo de vida

1. Migrations do módulo (ordem `2026_07_23_000001..000003`, `2026_07_30`, `2026_07_31`).
2. `ModuleProvider` é instanciado no boot; as rotas só são mapeadas depois de o provider existir.
3. Tabels de Core necessárias (`v2_conexao_tags`, `v2_gallery_media`, `v2_news`, `v2_news_categories`,
   `v2_associado_instituicoes`, `v2_portal_configs`) precisam existir antes do uso — as queries
   do portal toleram ausência apenas no bloco de `theme_regionais_bg` (`try/catch` vazio).
4. O `saved` do model exige `PublicIndexerService` e `RegionalIdentityService` resolvíveis;
   falhas são logadas, não propagadas.

### 4.4 Receitas de uso

```php
// Criar unidade com token, como o CMS faz
$unidade = new \V2\Modules\Conexoes\UnidadeRegional\Models\UnidadeRegional();
$unidade->id = v2_uuid();
$unidade->name = 'SUCESU Distrito Federal';
$unidade->acronym = 'SUCESU-DF';
$unidade->state = 'DF';
$unidade->is_active = true;
$unidade->save();                       // gera slug, categoria regional e índice

$raw = $unidade->generateApiToken();    // 'asc_...' exibido uma única vez
```

Resultado: linha em `v2_unidade_regionais`, categoria `regional-sucesu-df` sob `regionais`,
entrada na busca pública e token com expiração de 365 dias.

```bash
curl -s -X POST https://<host>/api/v2/unidaderegional/noticias \
  -H "Authorization: Bearer asc_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"title":"Regional publica","content":"## Texto\n\nMarkdown.","content_date":"2026-10-01"}'
```

Resultado: `201`, notícia `pending_approval` com `metadata.source_unidade_regional_id` e
`category_id` da subcategoria da unidade.

```php
// Consumir o escopo de notícias de uma regional (inclui CMS + API)
$noticias = \V2\Modules\Publicacoes\Noticias\Models\News::published()
    ->byRegional($unidade->id)
    ->orderByDesc('published_at')
    ->take(12)
    ->get();
```

Resultado: notícias publicadas das duas origens de vínculo (FK ou metadata).

```blade
{{-- Exibir o selo de token em uma tela CMS --}}
{!! $unidade->getTokenStatusBadge() !!}
```

Resultado: `<span class="badge bg-success">Ativo</span>`, `Expirado` ou `Expirando (n dias)`.

### 4.5 Anti-uso

- **Enviar `category_id` na criação de notícia** esperando que valha: é ignorado e sobrescrito
  por `assignRegionaisCategory()`. Para mudar a categoria, mude a sigla da unidade.
- **Usar o token de uma unidade para “administrar todas”**: todo `index/update/delete` é
  recortado pela unidade do token; outro ID retorna `404`/`403`, não sucesso.
- **Chamar `save()` na unidade em loop de alta frequência** (ex.: telemetria): cada save
  reindexa a busca e sincroniza categoria; use `updateQuietly`/query builder se o efeito não
  for desejado — sabendo que o índice ficará desatualizado.

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

- Limite de upload do CMS: `max:4096` (4 MB) por imagem e MIME `jpeg,png,jpg,webp`
  (`Controllers/UnidadeRegionalCmsController.php:315-318`); a API baixa imagens com limite de
  `GalleryConfig(..., 10240, false)` (10 MB **declarado**, mas não verificado pelo `GalleryService`).
- `content` da notícia: até 100.000 caracteres; `title` 255; `tags` até 10 itens de 50 chars.
- `empresa_ids`: 1..100 por requisição.
- Throttle: 30/min por prefixo de token (`throttle.api:30,1`) **somente** em POST; 100/min por
  IP (`throttle:100,1`) nos mesmos POST; GET/DELETE sem throttle (SEC-G1-14).
- Page cache público: 5 min (`PageCacheMiddleware::$ttl = 5`) e `Cache-Control: public,
  max-age=60` (`PublicCacheMiddleware`); toda escrita do módulo derruba `page:*`.
- `GET /openapi.json`: `Cache-Control: public, max-age=3600`.

## 5. Rotas e contratos HTTP

Resumo (detalhe em [`rotas.md`](rotas.md) e [`api.md`](api.md)):

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito |
|---|---|---|---|---|---|---|---|
| CMS grupo | — | `cms/unidades-regionais` | — | `web`, `module.active:UnidadeRegional`, `CmsAuthenticate` | — | RBAC `UnidadeRegional` | agrupa 16 endpoints |
| CMS | GET | `/cms/unidades-regionais` | `v2.unidade-regional.cms.index` | idem | `UnidadeRegionalCmsController@index` | read | lista 15/página |
| CMS | GET | `/cms/unidades-regionais/nova` | `...cms.create` | idem | `@create` | write | formulário |
| CMS | POST | `/cms/unidades-regionais/nova` | `...cms.store` | idem + CSRF | `@store` | write | cria + flash |
| CMS | GET | `/cms/unidades-regionais/editar/{id}` | `...cms.edit` | idem | `@edit` | read | edição com preview |
| CMS | PUT/POST | `/cms/unidades-regionais/editar/{id}` | `...cms.update` | idem + CSRF | `@update` | write | atualiza + galeria |
| CMS | DELETE | `/cms/unidades-regionais/{id}` | `...cms.destroy` | idem + CSRF | `@destroy` | write | arquiva + revoga token |
| API | GET | `/api/v2/unidaderegional/noticias` | `...api.noticias.index` | `api`,`bindings` | `UnidadeRegionalNewsApiController@index` | Bearer | lista da unidade |
| API | POST | `/api/v2/unidaderegional/noticias` | `...api.noticias.store` | + throttle | `@store` | Bearer | cria `pending_approval` |
| Portal | GET | `/regionais` | `v2.unidade-regional.portal.index` | `web`, maintenance, page/public cache | `UnidadeRegionalPortalController@index` | pública | lista 12/página |
| Portal | GET | `/regional/{slug}` | `v2.unidade-regional.portal.show` | idem | `@show` | pública | detalhe + SEO |
| OpenAPI | GET | `/api/v2/unidaderegional/openapi.json` | `...api.openapi` | `api`,`bindings` | `UnidadeRegionalApiController@openapi` | pública | spec JSON 3.0.3 |

## 6. Dados e modelo de domínio

```mermaid
erDiagram
  v2_unidade_regionais ||--o{ v2_unidade_regional_empresas : "unidade_regional_id"
  v2_unidade_regionais ||--o{ v2_unidade_regional_media : "unidade_regional_id"
  v2_unidade_regionais ||--o{ v2_unidade_regional_tag_pivot : "unidade_regional_id"
  v2_conexao_tags ||--o{ v2_unidade_regional_tag_pivot : "tag_id"
  v2_associado_instituicoes ||--o{ v2_unidade_regional_empresas : "empresa_id"
  v2_gallery_media ||--o{ v2_unidade_regional_media : "gallery_media_id"
  v2_news_categories ||--o{ v2_news : "category_id"
  v2_unidade_regionais ||--o{ v2_news : "unidade_regional_id"
  v2_unidade_regionais {
    uuid id PK
    string slug UK
    string acronym
    string state
    boolean is_active
    jsonb metadata
    string api_token_hash
    string api_token_prefix
    timestamp api_token_expires_at
  }
  v2_unidade_regional_empresas {
    uuid id PK
    uuid unidade_regional_id
    uuid empresa_id
    string status
    timestamp deleted_at
  }
  v2_unidade_regional_media {
    uuid id PK
    uuid unidade_regional_id
    string image
    int gallery_media_id
    int sort_order
  }
```

Detalhe completo (colunas, casts, índices, migrations) em [`dados.md`](dados.md). Resumo:

| Tabela | Papel | PK | Soft delete | Quem escreve |
|---|---|---|---|---|
| `v2_unidade_regionais` | registro canônico da regional + credencial | `uuid id` | sim | CMS, token, seeder |
| `v2_unidade_regional_empresas` | vínculo N:N regional↔associado com `status` | `uuid id` (+ unique `(unidade_regional_id, empresa_id)`) | sim (migration `2026_07_31`) | API de associados |
| `v2_unidade_regional_media` | espelho da galeria com `sort_order` | `uuid id` | não | CMS (`update`/`uploadGallery`) |
| `v2_unidade_regional_tag_pivot` | tags da regional | composta `(unidade_regional_id, tag_id)` | não | CMS (`syncTagsFromArray`) |

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Ativa: store (is_active=true)
  Ativa --> Ativa: update / save
  Ativa --> Inativa: update is_active=false
  Ativa --> Arquivada: destroy
  Inativa --> Ativa: update is_active=true
  Arquivada --> Arquivada: restore? não (restore só de soft delete)
  Arquivada --> Ativa: restore (deleted_at=null)
```

Observação: `destroy()` faz **duas** coisas — `is_active = false` e soft delete. `restore()`
desfaz apenas o soft delete; `is_active` permanece `false` (`Controllers/UnidadeRegionalCmsController.php:79-93`).

Fluxos:

```mermaid
sequenceDiagram
  participant C as CMS
  participant M as UnidadeRegional
  participant R as RegionalIdentityService
  participant S as PublicIndexer
  C->>M: save() (name, acronym, metadata)
  M->>M: creating/saving (uuid, slug único)
  M->>S: syncToSearch() (index ou remove)
  M->>R: sync() -> newsCategoryFor()
  R-->>M: categoria regional-<sigla>
  C->>M: syncTagsFromArray()
  C->>M: syncGalleryMediaIds()
```

- **FL-UNIDADEREGIONAL-01 (criar unidade)**: validate → model → logo/capa → metadata → save →
  clearPageCache → tags → activity → flash → redirect index.
- **FL-UNIDADEREGIONAL-02 (gerar token)**: `findOrFail` → `generateApiToken()` (sha256 + save) →
  activity `api_token_generated` → JSON com `raw_token`.
- **FL-UNIDADEREGIONAL-03 (autenticar API)**: Bearer → sha256 → lookup → `isApiTokenValid` →
  `last_used_at` + `save()` → controller.
- **FL-UNIDADEREGIONAL-04 (criar notícia pela API)**: FormRequest → token → imagem de capa →
  Markdown→Tiptap → model (`pending_approval`, metadata) → categoria → save → tag da sigla →
  activity → cache → `201`.
- **FL-UNIDADEREGIONAL-05 (arquivar associado)**: token → vínculo por `id` → `delete()` do
  vínculo → soft delete do associado → activity → cache → `200`.
- **FL-UNIDADEREGIONAL-06 (renderizar portal)**: rota → `active()`/`firstOrFail` → SEO →
  descrição Tiptap→HTML → notícias publicadas (limite 12) → associados ativos → JSON
  client-side → view.

## 8. UI CMS

Telas em [`cms.md`](cms.md). Síntese:

| Tela | Rota | Objetivo |
|---|---|---|
| Lista | `cms/unidades-regionais` | busca/filtro UF, status, badge de token, ações (portal, editar, arquivar/restaurar) |
| Formulário | `/nova` e `/editar/{id}` | dados cadastrais, tags, mídia, descrição rica, galeria, token, notícias e associados recentes |
| Notícias | `/{id}/noticias` | notícias enviadas pela API (15/página) com link para o CMS de Notícias |
| Documentação | `/documentacao` | guia, rate limit, endpoints, exemplos em 6 linguagens, Swagger UI e URL do OpenAPI |
| Modal fundo | lista | grava `theme_regionais_bg` a partir de uma mídia da galeria |

## 9. UI Portal

Páginas em [`portal.md`](portal.md). Síntese: `/regionais` (hero com fundo configurável,
cards com logo 185×185, cidade/UF, resumo de 250 chars, paginação 12) e `/regional/{slug}`
(hero com capa/variantes WebP ou gradiente do tenant, logo, sigla, cidade/UF, resumo,
descrição rica + card de informações, galeria com modal, associados em grid client-side com
busca por regex e "Ver mais" de 20 em 20, até 12 notícias com destaque).

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

| Chave | Default | Efeito | Fonte |
|---|---|---|---|
| `v2.unidade_regional.api_token_ttl_days` | **inexistente** (fallback 365) | TTL do token; sempre 365 | `Models/UnidadeRegional.php:283-285`; `config/v2.php:1-70` |
| `cache.prefix` | `laravel` | prefixo do padrão `page:*` limpo nas escritas | `Controllers/UnidadeRegionalCmsController.php:527-529` |
| `filesystems.media_disk` | `public` | disco de leitura/gravação de mídias | `V2/Services/Storage/StorageService.php:35-36` |
| `app.name` | — | títulos SEO do portal | `Controllers/UnidadeRegionalPortalController.php:32-34` |
| `v2.support.email` | — | bypass total do RBAC em `v2_can()` | `V2/Core/Helpers/helpers.php:12-27` |
| flag `UnidadeRegional` em `v2_module_flags` | ativa quando ausente | `module.active` bloqueia CMS com 404/redirect | `V2/Core/Services/ModuleFlagService.php:72-95` |

Permissões: chave de módulo `UnidadeRegional` (nome do diretório) no JSON `v2_roles.permissions_v2`;
`read` libera GET, `write` libera tudo (`V2/Core/Services/PermissionService.php:40-63`). O perfil
`regional` semeado **não** inclui essa chave. Ações de escrita na view são envolvidas por
`@v2can('UnidadeRegional', 'write')` (`Views/cms/index.blade.php:136-158`;
`Views/cms/create.blade.php:54-59`).

## 11. Observabilidade, erros e limites

- **Logs** (canal default, nível `info`/`warning`/`error`):
  `[UnidadeRegionalNewsApi] Notícia criada|atualizada|arquivada via API`,
  `[UnidadeRegionalNewsApi] Falha ao baixar imagem de capa`, `[UnidadeRegionalAssociadosApi] ...`,
  `[UnidadeRegional] Falha ao sincronizar busca|prover identidade|remover do índice`,
  `[UnidadeRegionalPortal] show` (slug, ids, contagens), `[UnidadeRegionalCmsController] Falha ao
  limpar cache de páginas`.
- **Auditoria** (`ActivityLoggerService`): `created`, `updated`, `deleted`, `restored`,
  `gallery_uploaded`, `gallery_synced`, `gallery_synced` (dentro de `syncGalleryMediaIds`),
  `api_token_generated`, `api_token_revoked`, `noticia_archived`, `associados_synced`,
  `associado_archived`, `associado_link_removed`, `associado_updated`.
- **Erros da API**: envelope `ApiResponse` para 401/403/404/409; `422` no formato Laravel;
  `429` do `ThrottleApiToken` com corpo próprio; `429` do `throttle:100,1` no formato Laravel.
- **Limites**: upload CMS 4 MB; download de imagem com timeout de 15 s (limite de 10 MB apenas
  declarado, sem enforcement), SSL sem verificação (`verify_peer=false`), sem allowlist de
  esquema/host nem bloqueio de IP privado — **SSRF** detalhado em [`api.md`](api.md) §3.6;
  `per_page` sem teto; sem transações nas escritas CMS/API.

## 12. Seeds e dados iniciais

| Seeder | Tabela | Registro | Idempotência | Ordem |
|---|---|---|---|---|
| `NacionalUnidadeRegionalSeeder` | `v2_unidade_regionais` | `acronym = 'NAC'`, `name = 'Nacional'`, `slug = 'nacional'`, `state = 'BR'`, `metadata.resumo = 'Abrangência Nacional SUCESU'` | `firstOrCreate(['acronym' => 'NAC'])` | chamado primeiro em `DatabaseSeeder::run()` (`database/seeds/DatabaseSeeder.php:11`) |
| `CreateV2ConexaoTagsTable` (Core) | `v2_conexao_tags` | tag reservada `Geral` (`slug = 'geral'`) disponível para o picker | insert simples (reexecução duplicaria) | antes do uso do módulo |
| `SeedV2RegionalRole` (Core) | `v2_roles` | perfil `regional` = `{"Noticias":"write","Associados":"write"}` | upsert por slug, rollback = soft delete | independente do módulo |

Nenhuma migration do módulo semeia dados.

## 13. Lacunas

| ID | Lacuna | Onde procurar | Impacto |
|---|---|---|---|
| LG-UNIDADEREGIONAL-01 | Não há validação de unicidade de `acronym`; o comportamento esperado em produção (duas unidades com mesma sigla) não está definido | `Migrations/2026_07_23_000001`; testes `RegionalIdentityServiceTest` | categoria regional compartilhada e renomeações inesperadas |
| LG-UNIDADEREGIONAL-02 | `per_page` da API não tem limite máximo documentado nem implementado | `Controllers/UnidadeRegionalNewsApiController.php:69-71` | payloads grandes / DoS leve |
| LG-UNIDADEREGIONAL-03 | Motivo do `category` no payload de associados (coluna removida) não está registrado em README | `V2/Modules/Conexoes/Associados/Migrations/2024_01_01_000020...` | campo morto no contrato público |
| LG-UNIDADEREGIONAL-04 | Não foi possível determinar se `v2_news.cover_image_id` (string 36) sempre recebe inteiro da galeria em todos os ambientes | `V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000001:44`; `Models/News.php:150-153` | risco de cast implícito no PostgreSQL |
| LG-UNIDADEREGIONAL-05 | O efeito de `syncAll()` não é agendado em nenhum comando deste módulo; não há cron/console documentado | `RegionalIdentityService.php:108-120`; busca por `syncAll` | categoria pode ficar ausente após remoção manual |
| LG-UNIDADEREGIONAL-06 | `regionaisBg` cai em `assets/img/hero/breadcrumb.png` se a tabela `v2_portal_configs` falhar; não há teste desse fallback | `Controllers/UnidadeRegionalPortalController.php:38-45` | imagem quebrada silenciosa |

## 14. Checklist de paridade

- [ ] `PC-UNIDADEREGIONAL-01` — criar unidade com sigla `SUC-TST` → existe categoria `regional-suc-tst` sob `regionais` e entrada na busca `unidades-regionais` (`RegionalIdentityService.php:33-61`).
- [ ] `PC-UNIDADEREGIONAL-02` — gerar token → resposta contém `raw_token` iniciando `asc_`, `prefix` de 12 chars e `expires_at` a +365 dias (`Models/UnidadeRegional.php:277-291`).
- [ ] `PC-UNIDADEREGIONAL-03` — chamar API com token válido → `api_token_last_used_at` é atualizado na mesma requisição (`Controllers/UnidadeRegionalNewsApiController.php:466-467`).
- [ ] `PC-UNIDADEREGIONAL-04` — revogar token → requisição seguinte retorna `401 invalid_token` (`Models/UnidadeRegional.php:293-303`).
- [ ] `PC-UNIDADEREGIONAL-05` — criar notícia com `category_id` forjado → notícia recebe a categoria da unidade (`Controllers/UnidadeRegionalNewsApiController.php:555-570`).
- [ ] `PC-UNIDADEREGIONAL-06` — criar notícia → `status = pending_approval` e `metadata.source_unidade_regional_id` preenchido (`:214-235`).
- [ ] `PC-UNIDADEREGIONAL-07` — `GET /noticias` com token da unidade B → não lista notícias da unidade A (`:69`).
- [ ] `PC-UNIDADEREGIONAL-08` — `DELETE /noticias/{id}` de notícia já arquivada → `409 already_archived` (`:158-164`).
- [ ] `PC-UNIDADEREGIONAL-09` — `POST /associados` com `empresa_ids` inexistente → `201` com item `success=false`, `error="Associado não encontrado"` (`Controllers/UnidadeRegionalEmpresaApiController.php:437-447`).
- [ ] `PC-UNIDADEREGIONAL-10` — `DELETE /associados/{id}` → vínculo soft-deleted **e** associado soft-deleted (`:194-225`).
- [ ] `PC-UNIDADEREGIONAL-11` — `POST /associados/{id}` com associado de outra unidade → `403 forbidden` (`:253-263`).
- [ ] `PC-UNIDADEREGIONAL-12` — 31º POST em 1 min → `429` com `retry_after` (`V2/Core/Middleware/ThrottleApiToken.php:35-47`).
- [ ] `PC-UNIDADEREGIONAL-13` — `GET /regionais` → somente `is_active = true`, 12 por página (`Controllers/UnidadeRegionalPortalController.php:30-31`).
- [ ] `PC-UNIDADEREGIONAL-14` — `GET /regional/{slug}` de unidade inativa → `404` (`:47-51`).
- [ ] `PC-UNIDADEREGIONAL-15` — arquivar unidade → `is_active=false`, `deleted_at` preenchido e token revogado (`Controllers/UnidadeRegionalCmsController.php:267-288`).
- [ ] `PC-UNIDADEREGIONAL-16` — `saved` da unidade remove do índice quando `is_active=false` (`Models/UnidadeRegional.php:158-165`).
- [ ] `PC-UNIDADEREGIONAL-17` — `GET /api/v2/unidaderegional/openapi.json` → `200`, `openapi = 3.0.3`, header `Cache-Control: public, max-age=3600` (`Controllers/UnidadeRegionalApiController.php:25-38,338-345`).
- [ ] `PC-UNIDADEREGIONAL-18` — usuário sem `UnidadeRegional` no `permissions_v2` → CMS redireciona ao dashboard com erro (`V2/Core/Middleware/V2PermissionMiddleware.php:83-97`).

## 15. Evidências consultadas

- `V2/Modules/Conexoes/UnidadeRegional/MODULE.md:1-105`
- `V2/Modules/Conexoes/UnidadeRegional/ModuleProvider.php:7-34`
- `V2/Modules/Conexoes/UnidadeRegional/Routes/{api,cms,web}.php` (arquivos completos)
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalCmsController.php:27-537`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:22-609`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:18-529`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalApiController.php:25-347`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalPortalController.php:16-195`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:1-326`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegionalEmpresa.php:1-57`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegionalMedia.php:1-48`
- `V2/Modules/Conexoes/UnidadeRegional/Requests/{ApiNewsRequest,ApiEmpresaRequest}.php`
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/*` (5 arquivos)
- `V2/Modules/Conexoes/UnidadeRegional/Views/**` (9 blades, faixas citadas nos docs irmãos)
- `V2/Core/Providers/V2ServiceProvider.php:29-45,84-135,283-313`
- `V2/Core/Middleware/{CmsAuthenticate,V2PermissionMiddleware,ThrottleApiToken,PageCacheMiddleware,PublicCacheMiddleware,ModuleAccessMiddleware}.php`
- `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:1-137`
- `V2/Core/Services/RegionalScopeService.php:38-141`
- `V2/Core/Services/PermissionService.php:40-101`
- `V2/Core/Services/ModuleFlagService.php:72-95`
- `V2/Core/Helpers/helpers.php:12-70,259-299`
- `V2/Core/Models/ConexaoTag.php:8-45`; `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:18-52`
- `V2/Modules/Publicacoes/Noticias/Models/News.php:39-90,391-402,425-485`
- `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:23-70,221-240`
- `V2/Modules/Conexoes/Associados/Migrations/2024_01_01_000020_drop_category_from_associado_instituicoes.php:13-37`
- `V2/Services/Gallery/{DTOs/GalleryConfig.php,Services/GalleryService.php:53-140,Models/GalleryMedia.php}`
- `V2/Services/SEO/Services/SEOService.php:29-58`; `V2/Services/SEO/Controllers/SeoPortalController.php:363-385`
- `V2/Services/Search/{DTOs/PublicSearchDTO.php,Services/SearchService.php:74-78}`
- `V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`
- `V2/Modules/Publicacoes/Noticias/Services/MarkdownToTiptapConverter.php:41-85`
- `config/v2.php:1-70`; `config/filesystems.php:16-24`; `app/Http/Kernel.php:44-67`
- `database/seeds/{DatabaseSeeder,NacionalUnidadeRegionalSeeder}.php`; `V2/Core/Migrations/2026_09_14_000002_seed_v2_regional_role.php:13-50`
- `tests/Feature/V2/Modules/Conexoes/UnidadeRegional/*`; `tests/Unit/V2/Modules/Conexoes/UnidadeRegional/*`
- `sucesu_analise/_qa/security-g1.md:202-213` (SEC-G1-14)
