---
id: MOD-INSTITUCIONAL-HISTORIA
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-INSTITUCIONAL-HISTORIA]
fontes:
  - V2/Modules/Institucional/Historia/
depende_de: [CORE-BOOT, CORE-AUTH, CORE-CMS, CORE-RUNTIME, SVC-CONTENTRICHEDITOR, SVC-MEDIA, SVC-GALLERY, SVC-SEO, SVC-ACTIVITYLOGGER]
consumidores: [CORE-RUNTIME, SVC-SEO, UI-CMS, UI-PORTAL, SVC-DASHBOARD]
criticidade: media
dono: InstitucionalA
---

# Institucional/Historia

> Linha do tempo institucional: cinco (ou N) marcos com período textual livre, imagem da
> galeria e corpo em AST do ContentRichEditor, ordenáveis por AJAX, ativáveis por toggle e
> publicados numa única página `/historia`. O CMS tem apenas três superfícies reais —
> listagem/reordenação, editor (CRE + mídia + preview por iframe) e configuração de SEO —
> e a página pública não tem detalhe por marco.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Publica em `/historia` todos os marcos com `is_active=true`, na ordem de `sort_order`, com imagem e HTML gerado a partir do AST; o CMS cria, edita, reordena, ativa/desativa e arquiva (soft delete) esses marcos. |
| Entrada | `GET/POST /cms/historia*` (11 rotas, 11 ações) e `GET /historia`; campos `period`, `title`, `description` (JSON AST), `image_id`, `sort_order`, `is_active`, `ids[]`, `historia_seo[title|description|keywords]`; query `search`, `page`. |
| Saída | HTML `v2-historia::cms.*` (index/create/edit/config/preview) e `v2-historia::portal.index`; JSON `{"success":true}` (reorder) e `{"success":true,"is_active":bool,"message":"..."}` (toggle); `302` com flash; linhas em `v2_historia_milestones`, `activity_log`, `v2_portal_configs`; invalidação best-effort de `page:*` no Redis. |
| Efeitos colaterais | `activity_log` (created/updated/deleted/toggle, e tentativa de config), log de aplicação em falha de Redis e de renderização, gravação de 3 chaves `historia_seo_*`, exclusão do cache de página Redis (5 min) após cada mutação. |
| Particularidade central | O estado de publicação é um par (`is_active`, `sort_order`) manipulado por um toggle que grava `sort_order = -1` ao desativar e **não** reordena o restante; como a listagem ordena por `sort_order`, o marco desativado sobe para o topo. |
| Como se integra | View namespace `v2-historia` (`ModuleProvider` não registra nada; o provedor global `V2ServiceProvider` registra views/migrations/rotas); model `V2\Modules\Institucional\Historia\Models\Milestone`; rotas nomeadas `v2.historia.*`; consome `SVC-CONTENTRICHEDITOR` (AST→HTML), `SVC-MEDIA`/`SVC-GALLERY` (imagem), `SVC-SEO` (`setTitle/setDescription/setKeywords`). |
| Se removido, quebra | `/historia` cai no catch-all `BasicPage` e responde `404` (não há `v2_basic_pages` semeada para o slug, ver §12); o menu CMS perde o item posição 7; o sitemap deixa de listar `/historia` (`SeoPortalController::addHistory` só adiciona a URL, sem depender de registros); o Dashboard perde o rótulo de assunto `Marco Histórico`. |
| Superfície pública | `Milestone` (`getHtmlDescription`, `scopeActive`, `scopeOrdered`), `HistoriaPortalController@index`, `MilestoneCmsController@{index,create,store,edit,update,destroy,reorder,toggleActive,preview,config,updateConfig}`. |

## 1. Efeito

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

- **Visitante do portal**: abrir `/historia` e ver o hero “Nossa História”, um bloco de
  introdução fixo, um cartão por marco ativo (período em pílula, título, HTML do corpo,
  imagem alternando lado a cada índice) e um CTA final fixo (`Views/portal/index.blade.php:43-65,68-135,137-156`).
- **Operador CMS** (qualquer papel com permissão `read`/`write` no módulo `Historia`):
  listar marcos com busca por título/período, paginados de 20 em 20, subir/descer a ordem
  por AJAX e ativar/desativar por toggle (`Controllers/MilestoneCmsController.php:37-52,205-258`).
- **Operador com `write`**: criar marco (período, título, corpo CRE, imagem da galeria),
  editar, ver preview em iframe e arquivar (soft delete) (`:59-197,266-277`).
- **Operador com `write`**: editar as 3 chaves de SEO da página `/historia` na tela
  “Configurações” (`:284-339`). **Esse POST grava as chaves e depois responde 500**
  (ver §3.3, `RN-HISTORIA-12`).

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| Insere linha em `v2_historia_milestones` com `id=v2_uuid()`, `created_by` = id Sentinel do usuário (string), `sort_order` = próximo livre, `is_active=true` | `store` | `MilestoneCmsController.php:81-90,348-357` |
| Atualiza período, título, descrição, imagem e (se enviados) `sort_order`/`is_active`; preserva os demais | `update` | `MilestoneCmsController.php:150-160` |
| `deleted_at` preenchido (soft delete); a ordem dos restantes **não** é compactada | `destroy` | `MilestoneCmsController.php:180-197`; `Models/Milestone.php:26` |
| `sort_order = order+1` para cada id recebido em `ids[]`, **inclusive marcos inativos/arquivados** | `reorder` | `MilestoneCmsController.php:205-216` |
| Desativar: grava `is_active=false` e `sort_order=-1`, depois decrementa `sort_order` de todos os ativos com ordem maior | `toggleActive` | `MilestoneCmsController.php:229-237` |
| Reativar: grava `sort_order = max(ativo)+1` | `toggleActive` | `MilestoneCmsController.php:238-243` |
| Grava/atualiza `v2_portal_configs` nas chaves `historia_seo_title`, `historia_seo_description`, `historia_seo_keywords`, grupo `MODULOS` | `updateConfig` | `MilestoneCmsController.php:311-328` |
| Remove todas as chaves `page:*` do Redis (cache de página do portal) após cada mutação | `clearPageCache` | `MilestoneCmsController.php:365-377` |
| `activity_log`: `created`, `updated`, `deleted`, `updated` (toggle) e `updated` (config, quando chega a executar) | `ActivityLoggerService::log` | `MilestoneCmsController.php:94-97,164-167,189-192,248-251,330-332` |
| `/historia` renderiza `Article` JSON-LD fixo e envia `page_context=historia` ao gtag | view do portal | `Views/portal/index.blade.php:5-26` |

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

| Entrada | Resultado |
|---|---|
| `GET /historia` | `200` HTML com todos os marcos `is_active=true` ordenados por `sort_order, created_at`; se não houver nenhum, bloco “Em breve / Nossa linha do tempo está sendo construída.” (`HistoriaPortalController.php:70`; `Views/portal/index.blade.php:125-135`). Resposta elegível ao cache de página de 5 min (`PageCacheMiddleware.php:13-14,68-85`). |
| `GET /cms/historia?search=1965` | `200`, filtra `title ILIKE %1965%` **ou** `period ILIKE %1965%`, 20 por página (`MilestoneCmsController.php:41-51`). |
| `GET /cms/historia/editar/{id}` com id inexistente | `404` (ModelNotFoundException) (`:116`). |
| `POST /cms/historia/novo` válido | `302` para `v2.historia.cms.milestones.index`; flash `created_item` (`type=Marco Histórico`, `title`, `route`) — **sem** mensagem textual de sucesso (`:99-105`). |
| `POST /cms/historia/novo` com `period`/`title` vazios | `302` de volta com erros exibidos como chaves cruas (`validation.required`, `validation.max.string`), pois não existe `resources/lang`. |
| `POST /cms/historia/editar/{id}` válido (ou `PUT`) | `302` para a tela de edição do mesmo id; flash `Marco histórico atualizado com sucesso!` (`:169-171`). |
| `DELETE /cms/historia/{id}` | `302` para o índice; flash `Marco histórico "<título>" arquivado com sucesso!` (`:194-196`). |
| `POST /cms/historia/reorder` com `ids[]` | JSON `200 {"success":true}`; sem validação de tipo/id (`:205-216`). |
| `POST /cms/historia/{id}/toggle` | JSON `200 {"success":true,"is_active":bool,"message":"Marco \"<título>\" ativado|desativado com sucesso!"}` (`:253-257`). |
| `GET /cms/historia/preview/{id}` | `200` HTML standalone (sem layout CMS) com banner “Modo Preview — este conteúdo não está publicado” (`Views/cms/preview.blade.php:1-88`). |
| `GET /cms/historia/configuracoes` | `200` com os 3 valores atuais ou defaults (`:286-298`). |
| `POST /cms/historia/configuracoes` | **`500`** `Argument 1 passed to ActivityLoggerService::log() must be an instance of Illuminate\Database\Eloquent\Model, null given` **depois** de persistir as chaves; sem flash e sem redirect (`:307-339`; `V2/Services/ActivityLogger/ActivityLoggerService.php:20`). |
| Qualquer rota CMS sem sessão | `302` para `v2.cms.login` ou `401 Unauthorized.` em AJAX (`CmsAuthenticate.php:21-28`). |
| Rota CMS com módulo `Historia` desativado em `v2_module_flags` | `404` com `Modulo desativado.` (o middleware só redireciona quando `getPrefix()==='cms'`, e o prefixo real é `cms/historia`; AJAX recebe JSON 404) — ver `RN-HISTORIA-16`. |

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

- Não publica por estado editorial: `is_active` é o único gate de publicação; não há
  rascunho, aprovação, agendamento nem `published_at`.
- Não tem página de detalhe por marco: `/historia` é a única rota pública
  (`Routes/web.php:3-9`); o sitemap não gera URLs por marco (`SeoPortalController.php:339-350`).
- Não reordena por drag-and-drop e não persiste a ordem ao arquivar/desativar de forma
  consistente (só os botões subir/descer chamam `reorder`).
- Não apaga arquivos de imagem ao arquivar o marco: só remove o vínculo lógico
  (`image_id` continua na linha).
- Não grava ALT da imagem: o `media-input` envia `image_id_alt`, mas o controller só lê
  `image_id` (`Views/cms/create.blade.php:88-97`; `SVC-MEDIA` `media-input.blade.php:46-47`).
- Não edita o texto de introdução, o hero, o CTA nem os flags de layout da página: são
  literais no Blade (`Views/portal/index.blade.php:43-65,137-156`).
- Não telemetra acesso próprio: quem grava visita de página é o `SEOService::trackAccess()`
  do composer global de metadata, não o módulo.
- Não indexa marcos no serviço de busca: não há referência a `Milestone` em
  `V2/Services/Search` (verificado por busca no diretório).

## 2. Abordagem

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

Um CRUD mínimo sobre uma tabela única, com três decisões estruturais: (a) a ordem é um
inteiro denso mantido à mão (`1..N`) e alterado em bloco por `ids[]`; (b) o corpo usa o
mesmo AST JSON do ContentRichEditor dos demais módulos e é renderizado sob demanda no
model; (c) a imagem é apenas um `image_id` opcional com resolução defensiva no portal,
porque há linhas antigas com caminho de arquivo em vez de id da galeria.

```mermaid
flowchart LR
  A[CMS index] -->|subir/descer| B[POST reorder ids]
  A -->|toggle| C[POST {id}/toggle]
  D[create/edit] -->|POST/PUT| E[validação]
  E --> F[Milestone save]
  F --> G[clearPageCache page:*]
  G --> H[activity_log]
  B --> F2[update sort_order em lote]
  F2 --> G
  C --> F3[is_active + sort_order]
  F3 --> G
  I[GET /historia] --> J[active()->ordered()->get]
  J --> K[resolveMilestoneImages em lote]
  K --> L[Blade: getHtmlDescription + picture]
```

### 2.2 Mecânica passo a passo

1. **Boot**: `V2ServiceProvider` varre `V2/Modules/<Grupo>/<Modulo>`, instancia
   `ModuleProvider` (aqui só devolve navegação), registra `Views` como `v2-historia`,
   carrega `Migrations`, e mapeia `Routes/cms.php` sob `web` + `module.active:Historia`;
   `Routes/web.php` é mapeado sob `web` + `MaintenanceMiddleware` + `page.cache` +
   `public.cache` (`V2/Core/Providers/V2ServiceProvider.php:96-134,203-224,293-307`).
2. **Listagem**: `index` monta `Milestone::query()`, aplica `ilike` opcional, ordena por
   `sort_order ASC, created_at ASC` e pagina 20 (`MilestoneCmsController.php:37-51`).
3. **Criação**: `store` valida 6 campos, atribui `id=v2_uuid()`, calcula a ordem com
   `nextSortOrder` (usa o `sort_order` enviado se não vazio; senão `max(sort_order)+1`,
   começando em 1), grava `created_by` com o id Sentinel, salva, limpa cache, audita e
   redireciona (`:70-106,348-357`).
4. **Edição**: `edit` busca com `findOrFail` e resolve a imagem **somente** se `image_id`
   for numérico e existir em `GalleryMedia`; injeta a relação ad hoc `image` (`:114-127`).
   `update` valida os mesmos campos e preserva `sort_order`/`is_active` quando ausentes do
   POST (`:141-160`).
5. **Arquivo**: `destroy` faz soft delete, limpa cache, audita com o título capturado antes
   e redireciona (`:180-197`).
6. **Ordem**: o Blade monitora cliques em `.btn-move-up/.btn-move-down`, move a `<tr>`,
   coleta todos os `data-id` do `tbody` e envia `ids[]`; o controller faz um `UPDATE` por
   id com índice+1 (`Views/cms/index.blade.php:144-191`; `MilestoneCmsController.php:205-216`).
7. **Publicação**: o toggle desativa zerando para `-1` e compacta os ativos acima; reativar
   pega `max(ativo)+1`. Sempre limpa o cache e audita (`:224-258`).
8. **Portal**: `index` consulta `active()->ordered()`, resolve imagens em uma única query
   `whereIn`, define título/descrição/keywords no `SEOService` (com fallback em 3 leituras a
   `v2_portal_configs`) e devolve a view (`HistoriaPortalController.php:35-94`). O Blade
   alterna o lado do cartão por `$idx % 2`, renderiza `getHtmlDescription()` e usa o
   componente `picture` com `variants`/`alt_text` (`Views/portal/index.blade.php:68-124`).

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

- **Ordem como inteiro denso, não `position` fracionária**: simplifica o reorder por
  lista completa, mas cria o custo de N updates e o estado inconsistente após toggle e
  delete `[C]` (`MilestoneCmsController.php:205-243`).
- **`-1` como “desativado”**: marca o item sem removê-lo da listagem, mas como a listagem
  ordena por `sort_order`, itens desativados aparecem **antes** dos ativos `[C]`
  (`:232,49`). Não há filtro `active` na tela CMS `[C]`.
- **`nextSortOrder` só considera `max(sort_order)`**: se o único marco existente estiver
  desativado (`-1`), o próximo criado recebe `0`; após um reorder, itens inativos recebem
  ordem positiva `[C]` (`:348-357`; `:209-211`).
- **AST renderizado no model** (`getHtmlDescription`) para que portal e preview compartilhem
  a mesma conversão e para encapsular o fallback de erro `[C]` (`Models/Milestone.php:65-77`).
- **`image_id` polimórfico por acidente**: aceita id numérico da galeria **ou** caminho
  legado; o portal filtra apenas numéricos e ignora o resto silenciosamente `[C]`
  (`HistoriaPortalController.php:37-58`).
- **`created_by` gravado mas nunca exibido nem filtrado**: armazena o id do usuário Sentinel
  (inteiro da tabela `users`, convertido pela coluna `string(36)`) `[C]`/`[I]`
  (`MilestoneCmsController.php:89`; migration `:24`).
- **SEO de página em `v2_portal_configs` em vez de `metadata`**: única página institucional
  com 3 chaves globais; o `seo-input` é reutilizado com prefixo próprio `historia_seo` `[C]`
  (`Views/cms/config.blade.php:28-33`; `03_servicos-ui/seo/contrato.md:314`).

### 2.4 Trechos de código pivô

```php
// V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:224-243 — o par (is_active, sort_order) e o -1
$milestone = Milestone::findOrFail($id);
$wasActive = $milestone->is_active;

if ($wasActive) {
    $oldOrder = $milestone->sort_order;
    $milestone->is_active = false;
    $milestone->sort_order = -1;
    $milestone->save();

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

Mostra a regra central: desativar não remove a linha, empurra para `-1` e compacta os
ativos; reativar sempre vai para o fim. Consequência: a listagem CMS (sem filtro) mostra
desativados no topo e um `reorder` posterior desfaz o `-1`.

```php
// V2/Modules/Institucional/Historia/Controllers/HistoriaPortalController.php:35-61 — resolução em lote e tolerância a legado
$validIds = $milestones->pluck('image_id')
    ->filter(function ($id) {
        return !empty($id) && is_numeric($id);
    })
    ->unique()->values()->toArray();

$mediaRecords = !empty($validIds)
    ? GalleryMedia::whereIn('id', $validIds)->get()->keyBy('id')
    : collect();

$milestones->each(function ($milestone) use ($mediaRecords) {
    $id = $milestone->image_id;
    if (!empty($id) && is_numeric($id) && $mediaRecords->has($id)) {
        $milestone->setRelation('image', $mediaRecords->get($id));
    }
});
```

O contrato público do `image_id`: id numérico da galeria resolve; caminho legado ou id
inexistente não gera erro nem fallback — o Blade simplesmente não renderiza imagem
(`Views/portal/index.blade.php:89-109`). Uma query por página, não N+1.

```php
// V2/Modules/Institucional/Historia/Models/Milestone.php:65-77 — renderização e fallback
public function getHtmlDescription()
{
    if (empty($this->description)) {
        return '';
    }
    try {
        $renderer = app(ContentRichEditorRendererService::class);
        return $renderer->toHtml($this->description);
    } catch (\Exception $e) {
        \Log::error('[Milestone] Erro ao renderizar descrição: ' . $e->getMessage());
        return e($this->description);
    }
}
```

`ContentRichEditorRendererService::toHtml` devolve `[]` para JSON inválido
(`ContentRichEditorRendererService.php:14-37`), portanto texto puro gravado em
`description` **não** cai no `catch` — renderiza vazio. O fallback `e()` só cobre exceções
reais.

```php
// V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:307-332 — config de SEO e auditoria impossível
$seo = $request->input('historia_seo', []);
$now = now();
$configs = [
    'historia_seo_title'       => $seo['title'] ?? '',
    'historia_seo_description' => $seo['description'] ?? '',
    'historia_seo_keywords'    => $seo['keywords'] ?? '',
];
foreach ($configs as $key => $value) {
    \DB::table('v2_portal_configs')->updateOrInsert(
        ['config_key' => $key],
        ['config_value' => $value, 'config_title' => $key,
         'config_group' => 'MODULOS', 'updated_at' => $now]
    );
}
$this->activityLogger->log(null, 'updated', ['type' => 'historia_config_seo_update']);
```

As três chaves são gravadas antes da chamada de auditoria; como `ActivityLoggerService::log`
exige `Model` não anulável (`V2/Services/ActivityLogger/ActivityLoggerService.php:20`), o
`null` dispara `TypeError` e a requisição termina em `500` — sem flash, sem redirect e sem
`clearPageCache` (`:334-338`). `updateOrInsert` não define `created_at` no insert.

```php
// V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:365-377 — invalidação de cache best-effort
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('[MilestoneCmsController] Falha ao limpar cache de páginas: ' . $e->getMessage());
}
```

Usa `KEYS` (bloqueante) sobre o prefixo do cache Redis do Laravel, que é o mesmo do
`PageCacheMiddleware` (`page:<sha1(fullUrl)>`) — na prática apaga o cache de **todas** as
páginas, não só `/historia`. Falha é silenciosa para o usuário.

```blade
{{-- V2/Modules/Institucional/Historia/Views/cms/create.blade.php:75-108 — contrato de UI do editor --}}
<div class="mt-4 editor-split">
    <div class="editor-left-panel">
        @include('v2-service-contentricheditor::components.forms.content-rich-editor', [
            'name' => 'description', 'label' => 'Conteúdo Narrativo',
            'value' => old('description', '{"type":"doc","content":[]}'),
            'placeholder' => 'Descreva este marco histórico...', 'height' => '400px',
        ])
    </div>
    <div class="editor-right-panel">
        @include('v2-service-media::components.media-input', [
            'name' => 'image_id', 'label' => 'Imagem de Destaque', 'accept' => 'image/*',
            'required' => false, 'ownerModule' => 'historia',
            'currentId' => '', 'currentUrl' => '', 'currentAlt' => '',
        ])
        ...
        <p class="mt-3 mb-0">Salve o marco como rascunho para ver o preview.</p>
```

O editor tem duas colunas: CRE à esquerda (envia `description` como AST JSON) e mídia +
preview à direita. Na criação o preview é um placeholder; só a edição injeta
`iframe#live-preview-iframe` (`Views/cms/edit.blade.php:89-111`). O `media-input` também
envia `image_id_alt`, que o controller descarta.

```blade
{{-- V2/Modules/Institucional/Historia/Views/cms/index.blade.php:154-175 — reorder otimista por lista de ids --}}
function updateOrder() {
    var ids = [];
    $('#milestones-tbody tr').each(function() {
        ids.push($(this).data('id'));
    });
    $.ajax({
        url: reorderUrl, method: 'POST',
        data: { _token: csrfToken, ids: ids },
        success: function() { location.reload(); },
        error: function() {
            alert('Erro ao reordenar. Tente novamente.');
            location.reload();
        }
    });
}
```

O navegador reordena o DOM, envia **todos** os ids (inclusive inativos) e recarrega a
página; o servidor não valida tamanho, pertencimento nem tipos. Sem `ids`, o POST responde
`{"success":true}` sem efeito (`MilestoneCmsController.php:207-215`).

### 2.5 Algoritmos, consultas e custos

- **Listagem CMS**: `SELECT * FROM v2_historia_milestones ORDER BY sort_order, created_at LIMIT 20 OFFSET …`;
  busca usa `ILIKE` (Postgres) sem índice dedicado; índices existentes são
  `idx_v2_hist_milestones_active` e `idx_v2_hist_milestones_order` (migration `:28-29`).
  Soft delete adiciona `deleted_at IS NULL`.
- **Portal**: uma query de marcos + uma query `whereIn` de mídias; `picture` pode emitir
  `<source>` por variante. Custo O(marcos ativos).
- **Reorder**: N updates independentes, sem transação; se o processo falhar no meio, a
  ordem fica parcial (não há atomicidade).
- **nextSortOrder**: `SELECT max(sort_order)`; após desativações o máximo pode não ser o
  “fim” visual, porque inativos valem `-1`/positivos herdados.
- **Cache**: `/historia` fica 5 min em Redis com chave `page:<sha1(fullUrl)>`
  (`PageCacheMiddleware.php:13-14,88-91`). Toda mutação tenta apagar `page:*`; o portal
  pode servir HTML antigo por até 5 min se o Redis falhar.

## 3. Particularidades

### 3.1 O que só existe aqui

- **Toggle com `-1` e compactação só dos ativos** — nenhum outro módulo usa `sort_order=-1`
  como estado; consequentemente a ordenação da listagem CMS mistura ativos e inativos sem
  separação visual (`MilestoneCmsController.php:229-243`; `Views/cms/index.blade.php:49-118`).
- **`image_id` aceitando id numérico ou caminho legado**, com filtro `is_numeric` em dois
  pontos (`HistoriaPortalController.php:37-58`; `MilestoneCmsController.php:118-124`).
- **Configuração de página restrita a SEO**, com prefixo `historia_seo` (não
  `metadata[seo]`) e persistência em `v2_portal_configs` (`Views/cms/config.blade.php:28-33`;
  `MilestoneCmsController.php:311-328`).
- **Preview isolado em iframe com documento HTML completo**, sem layout CMS e com
  `sandbox="allow-scripts allow-same-origin"` (`Views/cms/preview.blade.php:1-88`;
  `Views/cms/edit.blade.php:108-110`).
- **`historia_seo_*` é a única configuração de página institucional fora de `metadata`** —
  o `seo-input` é reutilizado com um prefixo divergente e o handler ignora
  `image_id`, `image_url` e `json_ld` que o componente envia (ver `03_servicos-ui/seo/contrato.md:429`).

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-HISTORIA-01` | Só marcos com `is_active=true` aparecem no portal; a ordenação é `sort_order ASC, created_at ASC`. | Marco desativado desaparece de `/historia` e continua na listagem CMS. | `Models/Milestone.php:85-97`; `HistoriaPortalController.php:70` |
| `RN-HISTORIA-02` | `period` (≤100) e `title` (≤255) são obrigatórios; `description` e `image_id` (≤36) opcionais. | POST inválido retorna à tela com erros; nada é gravado. | `MilestoneCmsController.php:72-79,141-148` |
| `RN-HISTORIA-03` | `sort_order` só é usado no `store` se veio preenchido; no `update`, só muda se enviado. | Formulários atuais não têm esse campo; criação sempre acrescenta no fim, edição preserva. | `MilestoneCmsController.php:87,154-156,348-357`; `Views/cms/_form.blade.php:6-27` |
| `RN-HISTORIA-04` | `is_active` nunca é editado pelo formulário: `has('is_active')` é falso no create/edit. | Criação nasce ativa; edição preserva o estado; só o toggle muda. | `MilestoneCmsController.php:88,157-159` |
| `RN-HISTORIA-05` | Desativar grava `sort_order=-1` e decrementa em 1 todos os ativos com ordem maior. | A ordem relativa dos ativos é preservada; a do desativado é destruída. | `MilestoneCmsController.php:229-237` |
| `RN-HISTORIA-06` | Reativar usa `max(sort_order dos ativos)+1`; se não houver ativos, `0+1`. | Reativado vai para o fim. | `MilestoneCmsController.php:238-243` |
| `RN-HISTORIA-07` | `reorder` grava `ordem=índice+1` para todo id recebido, sem filtrar ativos/arquivados e sem validar que os ids existem. | Um reorder pode dar ordem positiva a inativos e “ressuscitar” a semântica do `-1`. | `MilestoneCmsController.php:205-216` |
| `RN-HISTORIA-08` | Arquivar é soft delete e não compacta a ordem. | Buracos em `sort_order`; a listagem mantém a sequência relativa. | `MilestoneCmsController.php:180-197`; `Models/Milestone.php:26` |
| `RN-HISTORIA-09` | O portal resolve imagens em lote e ignora `image_id` não numérico ou inexistente. | Imagem legada não aparece; sem erro 500. | `HistoriaPortalController.php:35-61` |
| `RN-HISTORIA-10` | `description` é AST JSON; string inválida/vazia renderiza `''`. | Texto puro no campo aparece em branco no portal. | `Models/Milestone.php:65-77`; `ContentRichEditorRendererService.php:14-37` |
| `RN-HISTORIA-11` | A tela de configuração só persiste title/description/keywords; campos de imagem/JSON-LD do `seo-input` são descartados. | Imagem e JSON-LD configurados no componente não têm efeito. | `MilestoneCmsController.php:309-316`; `SVC-SEO` `seo-input.blade.php:56,84,101,140-141,166` |
| `RN-HISTORIA-12` | `updateConfig` chama `activityLogger->log(null, …)`, mas o método exige `Model`. | `TypeError` → HTTP 500 após gravar as 3 chaves; flash/redirect/cache não acontecem. | `MilestoneCmsController.php:330-338`; `ActivityLoggerService.php:20` |
| `RN-HISTORIA-13` | Toda mutação chama `clearPageCache`, que apaga `page:*` no Redis e engole exceções. | Cache global de páginas invalidado; falha de Redis só gera warning. | `MilestoneCmsController.php:365-377` |
| `RN-HISTORIA-14` | O grupo CMS declara `web` apesar de o provider já aplicá-lo; o merge de grupos (`array_merge_recursive`) mantém as duas entradas. | `web` aparece duplicado na pilha de middleware da rota. | `Routes/cms.php:3-6`; `V2ServiceProvider.php:299-306`; `vendor/laravel/framework/src/Illuminate/Routing/RouteGroup.php:31-35` |
| `RN-HISTORIA-15` | `active_pattern` é `['cms/historia*']`; o RBAC usa a chave `Historia` de `permissions_v2`. Suporte e `super-admin` passam direto. | Sem permissão `read`/`write`, o usuário é redirecionado ao dashboard com `Acesso negado...`. | `ModuleProvider.php:24`; `V2PermissionMiddleware.php:40-101` |
| `RN-HISTORIA-16` | `module.active:Historia` consulta a flag e, se inativa, tenta redirecionar apenas quando o prefixo da rota é exatamente `cms`; caso contrário, `abort(404)`. | Rotas CMS respondem `404 Modulo desativado.` (HTML) ou JSON 404; a rota pública não é afetada. | `ModuleAccessMiddleware.php:38-62`; `V2ServiceProvider.php:293-306`; `ModuleFlagService.php:82-88` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Listagem mostra desativados primeiro**: como o toggle grava `-1`, e não há filtro de
  status, o item recém-desativado aparece no topo da tabela com `Ordem = -1`.
- **Criar depois de desativar tudo**: `nextSortOrder` usa `max` global; se o único item está
  em `-1`, o novo recebe `0`; o primeiro item ativo pode ficar em `0`, não em `1` como o
  comentário do método sugere.
- **Apagar o último marco**: `destroy` não recalcula ordem; criar o próximo usa `max` de
  linhas **não deletadas** (o soft delete é aplicado ao model), então volta a `1` sem
  colisão.
- **`image_id` maior que 36 caracteres** falha na validação embora caminhos legados possam
  ser maiores — legado é aceito apenas se couber em 36 (impraticável); na prática só ids.
- **Preview sem imagem**: `preview()` não resolve a relação `image`, e o model não define
  `image()`; `$milestone->image` é `null`, então o iframe nunca mostra a imagem, mesmo que
  o marco tenha `image_id` (`MilestoneCmsController.php:266-277`; `Views/cms/preview.blade.php:75-80`).
- **Preview desatualizado**: o iframe carrega `/cms/historia/preview/{id}` no `src`; após
  salvar, a tela de edição recarrega e o iframe busca o estado atual — não há refresh por
  JS, só o load da página.
- **Config POST quebrado**: o operador vê erro 500 mesmo com as chaves já gravadas; um
  segundo POST idempotente repete o 500. A tela de configuração continua exibindo os
  valores novos porque lê o banco.
- **Cache de página**: como o POST de config falha antes de `clearPageCache`, `/historia`
  pode continuar com o título/descrição antigos por até 5 min.
- **Marco arquivado e reorder**: o id da linha soft-deleted não está no DOM; se um cliente
  enviar esse id manualmente, o `update` em massa do `reorder` **não** respeita soft delete
  (`Milestone::where('id',$id)` aplica o global scope, então a linha deletada não é
  atualizada) — sem efeito.
- **Sem permissão de escrita**: `V2PermissionMiddleware` bloqueia POST/PUT/DELETE com
  redirect `Operação negada. Seu perfil possui apenas permissão de leitura para o módulo: História`
  (título do provider) (`V2PermissionMiddleware.php:101-125`; `ModuleProvider.php:20`).

### 3.4 Armadilhas de reimplementação

- **Não “corrigir” o toggle para `sort_order=null`** sem migrar/limpar os dados: a listagem
  e o reorder dependem de inteiros comparáveis; `null` vai para o fim no Postgres em `ASC`.
- **Não reaproveitar a lógica de `reorder` de outros módulos** sem considerar que aqui a
  lista inclui inativos; copiar o padrão de Notícias (que normalmente filtra publicação)
  muda o significado da ordem.
- **Não trocar `getHtmlDescription` por `{!! $milestone->description !!}`**: o campo é AST
  JSON; imprimir cru vaza JSON para a página.
- **Não tratar `image_id` como FK**: não há constraint, nem relação Eloquent; apagar mídia
  na galeria deixa o id órfão.
- **Não ligar `is_active` ao `<form>` sem ajustar `store`/`update`**: o checkbox enviaria
  `0`/`1` e o toggle passaria a competir com o formulário.
- **Não mover a config para `metadata['seo']`** sem migrar as 3 chaves: o portal lê
  `v2_portal_configs` por `config_key` (`HistoriaPortalController.php:77-87`).

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

- **`03_servicos-ui/seo/contrato.md:429` já registra a divergência**: História usa prefixo
  `historia_seo` e “imagem/JSON-LD do componente são descartados”. Confirmado no código
  (`MilestoneCmsController.php:309-316`).
- **Comentário de `nextSortOrder`** promete “inicia em 1”, mas `max()` global pode devolver
  `-1`/`0` (`MilestoneCmsController.php:341-357`).
- **Mensagem de criação ausente**: `store` só usa `session()->flash('created_item', …)`,
  dependendo do layout CMS para exibir; update/delete/toggle usam `flash()->success`.
- **`Log` com alias global** (`\Log::error`) no model, enquanto o controller importa
  `Illuminate\Support\Facades\Log` — dois estilos no mesmo módulo.
- **`DB::table('v2_portal_configs')` inline** no controller e no portal; não usa
  `PortalConfigsService` (que é o caminho documentado em `SupportConfigs`).
- **Falta seed das 3 chaves de SEO**: nenhuma migration/seeder grava `historia_seo_*`;
  a tela mostra defaults do código até o primeiro POST (que grava e responde 500).
- **`created_by` sem uso**: gravado, nunca exibido, sem FK (`migration:24`).
- **Sem `resources/lang`**: as mensagens de validação chegam ao usuário como chaves cruas
  (`validation.required`, `validation.max.string`), não como texto traduzido — vale para todo
  o corpus, não só para este módulo (`config/app.php:84,97`).
- **Docblock do `preview`** promete “formato do portal”, mas o CSS é uma cópia local e não
  usa o layout do portal (`Views/cms/preview.blade.php:8-68`).

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `Milestone` | model Eloquent | `$table='v2_historia_milestones'`, `$incrementing=false`, `$keyType='string'`, soft delete | tabela migrada | controllers do módulo, `HistoriaMilestonesSeeder`, `SVC-DASHBOARD` (rótulo) |
| `Milestone::scopeActive` | scope | `active()` → `where is_active=true` | — | `HistoriaPortalController` |
| `Milestone::scopeOrdered` | scope | `ordered()` → `orderBy sort_order, created_at` | — | portal e CMS |
| `Milestone::getHtmlDescription` | método | `(): string` (HTML confiável) | `description` em AST JSON válido para ter conteúdo | portal e preview |
| `v2_uuid()` | helper global | `(): string` UUID v4 | — | `store`, model bootstrap |

### 4.2 Contrato de UI/JS

- **Namespace de views** `v2-historia` → `Views/cms/{index,create,edit,config,preview,_form}.blade.php`
  e `Views/portal/index.blade.php` (`V2ServiceProvider.php:118-121`).
- **Componentes consumidos**: `v2-service-contentricheditor::components.forms.content-rich-editor`
  com props `name`, `label`, `value`, `placeholder`, `height` (AST JSON em `value`);
  `v2-service-media::components.media-input` com `name`, `label`, `accept`, `required`,
  `ownerModule`, `currentId`, `currentUrl`, `currentAlt` — envia hidden `image_id` e
  `image_id_alt` (`SVC-MEDIA` `media-input.blade.php:46-47`).
- **DOM/JS da listagem**: `tbody#milestones-tbody` com `tr[data-id]`; botões
  `.btn-move-up`, `.btn-move-down`, `.btn-toggle-active[data-id][data-active]`; contador
  `.milestone-order`; confirmação por `data-confirm-modal` + `#_confirm-modal` partial.
  Eventos delegados a `document`, inicializados em `$(document).ready` e sem teardown
  (a página recarrega após cada ação) (`Views/cms/index.blade.php:144-214`).
- **Preview**: `iframe#live-preview-iframe` com `sandbox="allow-scripts allow-same-origin"`,
  `title="Preview do marco histórico"`; sem postMessage.
- **A11y**: tabela com `aria-label="Linhas do tempo"` (rótulo divergente do conteúdo),
  botões só com ícone usam `title`; ícones de Bootstrap sem `aria-hidden` na listagem;
  mensagens de erro de validação usam `.invalid-feedback` (`Views/cms/index.blade.php:39`;
  `Views/cms/_form.blade.php:13-15`).

### 4.3 Ordem e ciclo de vida

1. `V2ServiceProvider::boot` descobre o módulo, registra provider config, migrations,
   views e rotas (CMS primeiro, portal em seguida, fallback por último).
2. `Routes/web.php` herda `MaintenanceMiddleware`, `page.cache` e `public.cache`; se o site
   está em manutenção, `/historia` responde a página de manutenção antes do controller.
3. O controller do portal depende de `SEOService` (singleton por request) e o layout global
   lê o estado no composer de metadata; a ordem é: controller seta → view renderiza →
   composer lê `getTitle()`.
4. No CMS, `CmsAuthenticate` exige Sentinel + ≥1 papel; em seguida `V2PermissionMiddleware`
   resolve o módulo por `active_pattern` e aplica `read`/`write`.
5. Mutação → save → `clearPageCache` → `activity_log` → flash/redirect (no config, a ordem
   quebra no log).

### 4.4 Receitas de uso

```php
// Consultar marcos publicados na ordem de exibição (qualquer consumidor)
$milestones = \V2\Modules\Institucional\Historia\Models\Milestone::active()
    ->ordered()
    ->get();

// Renderizar o corpo com segurança (AST → HTML)
echo $milestone->getHtmlDescription();
```

```php
// Criar um marco equivalente ao CMS (id e created_by são responsabilidade do chamador)
$m = new \V2\Modules\Institucional\Historia\Models\Milestone();
$m->id = v2_uuid();
$m->period = '1965 — 1967';
$m->title = 'O Nascimento em uma Sala de Vidro';
$m->description = json_encode(['type' => 'doc', 'content' => []]);
$m->sort_order = (\V2\Modules\Institucional\Historia\Models\Milestone::max('sort_order') ?? 0) + 1;
$m->is_active = true;
$m->save();
```

```blade
{{-- Renderizar um marco no portal com imagem opcional da galeria --}}
@if($media = $milestone->image)
    @include('v2-service-media::components.picture', [
        'originalPath' => $media->file_path,
        'variants'     => $media->variants ?? [],
        'alt'          => $media->alt_text ?: $milestone->title,
        'class'        => 'milestone-img img-fluid',
        'lazy'         => true,
    ])
@endif
```

Resultado esperado: `<picture>` com variantes WebP (se houver), fallback `<img>` com
`loading="lazy"` e `alt` nunca vazio (usa o título).

```bash
# Semear os 5 marcos iniciais (idempotente por título)
php artisan db:seed --class=V2\\Seeds\\BasicPages\\HistoriaMilestonesSeeder
```

### 4.5 Anti-uso

- **Ler a ordem de `updated_at`** para “prever” a exibição: o portal ordena por
  `sort_order, created_at`; `updated_at` não participa.
- **Chamar `Milestone::create([...])` sem `is_active`/`sort_order`**: a migration tem
  defaults (`true`/`0`), mas o CMS usa `max+1`; sem isso, todos os marcos ficam com ordem
  `0` e a ordem passa a ser decidida por `created_at`.
- **Reutilizar a tela `config` para editar conteúdo**: ela só lê/escreve SEO; os textos do
  hero e do CTA são literais no Blade.

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

- Paginação fixa de 20 na listagem CMS; sem limite máximo de `ids[]` no reorder.
- `clearPageCache` executa `KEYS page:*` (O(n) no Redis) a cada criação/edição/toggle/delete.
- `/historia` é cacheado 5 min em Redis; respostas AJAX e não-GET nunca são cacheadas
  (`PageCacheMiddleware.php:68-85`).
- Imagens: uma query `whereIn` por render; o componente pode gerar múltiplos `<source>` por
  imagem.
- `description` não tem limite de tamanho na validação (`string` apenas), mas a coluna é
  `text`; payloads grandes do CRE passam direto.

## 5. Rotas e contratos HTTP

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|---|
| grupo | — | `cms/historia` | — | `web`; `module.active:Historia`; `CmsAuthenticate`; `V2PermissionMiddleware` | — | módulo `Historia` | agrupa 11 endpoints | `Routes/cms.php:3-6`; `V2ServiceProvider.php:299-306` |
| endpoint | GET | `/cms/historia` | `v2.historia.cms.milestones.index` | idem | `MilestoneCmsController@index` | `Historia:read` | lista paginada (20) com busca | `Routes/cms.php:7-10` |
| endpoint | GET | `/cms/historia/novo` | `v2.historia.cms.milestones.create` | idem | `@create` | `read` | formulário de criação | `:12-15` |
| endpoint | POST | `/cms/historia/novo` | `v2.historia.cms.milestones.store` | idem | `@store` | `write` | cria e redireciona ao índice | `:17-20` |
| endpoint | GET | `/cms/historia/editar/{id}` | `v2.historia.cms.milestones.edit` | idem | `@edit` | `read` | formulário + iframe de preview | `:22-25` |
| endpoint | PUT/POST | `/cms/historia/editar/{id}` | `v2.historia.cms.milestones.update` | idem | `@update` | `write` | atualiza e volta à edição | `:27-30` |
| endpoint | DELETE | `/cms/historia/{id}` | `v2.historia.cms.milestones.destroy` | idem | `@destroy` | `write` | soft delete + flash | `:32-35` |
| endpoint | POST | `/cms/historia/reorder` | `v2.historia.cms.milestones.reorder` | idem | `@reorder` | `write` | JSON `{success:true}` | `:37-40` |
| endpoint | POST | `/cms/historia/{id}/toggle` | `v2.historia.cms.milestones.toggle` | idem | `@toggleActive` | `write` | JSON com `is_active`/`message` | `:42-45` |
| endpoint | GET | `/cms/historia/preview/{id}` | `v2.historia.cms.milestones.preview` | idem | `@preview` | `read` | HTML standalone | `:47-50` |
| endpoint | GET | `/cms/historia/configuracoes` | `v2.historia.cms.config` | idem | `@config` | `read` | formulário SEO | `:52-55` |
| endpoint | POST | `/cms/historia/configuracoes` | `v2.historia.cms.config.update` | idem | `@updateConfig` | `write` | grava 3 chaves e responde 500 | `:57-60` |
| grupo | — | `/historia` | — | `web`; `MaintenanceMiddleware`; `page.cache`; `public.cache` | — | pública | — | `Routes/web.php:3-5`; `V2ServiceProvider.php:215-219` |
| endpoint | GET | `/historia` | `v2.historia.portal.index` | idem | `HistoriaPortalController@index` | pública | HTML da linha do tempo (cache 5 min) | `Routes/web.php:6-9` |

Observações de contrato:
- `{id}` é UUID em string; `findOrFail` gera `404` para id inválido/inexistente.
- Não há throttle em nenhuma rota; CSRF é aplicado pelo `web` (exceto GET/HEAD).
- Respostas CMS são HTML ou JSON; os dois endpoints AJAX dependem de `X-CSRF-TOKEN` via
  `_token` no corpo.

## 6. Dados e modelo de domínio

Tabela única criada por `2026_07_20_000001_create_v2_historia_milestones_table.php`.

| Coluna | Tipo | Null | Default | Observação |
|---|---|---|---|---|
| `id` | `uuid` PK | não | — | preenchido em PHP (`v2_uuid()`), não pelo banco |
| `period` | `varchar(100)` | não | — | texto livre; aceita intervalos (`1965 — 1967`) |
| `title` | `varchar(255)` | não | — | usado também como `alt` fallback |
| `description` | `text` | sim | — | AST JSON do CRE |
| `image_id` | `varchar(36)` | sim | — | id da galeria **ou** caminho legado |
| `sort_order` | `integer` | não | `0` | `-1` = desativado pelo toggle |
| `is_active` | `boolean` | não | `true` | gate de publicação |
| `created_by` | `varchar(36)` | sim | — | id Sentinel; sem FK e sem uso de leitura |
| `created_at`/`updated_at` | `timestamp` | sim | — | Eloquent |
| `deleted_at` | `timestamp` | sim | — | soft delete |

Índices: `idx_v2_hist_milestones_active (is_active)`, `idx_v2_hist_milestones_order (sort_order)`.
Sem unique constraints e sem FKs (migration `:16-30`).

Model: `$table`, `$incrementing=false`, `$keyType='string'`, `$fillable` com 7 campos,
`$casts=['is_active'=>'boolean']`, `$dates` com timestamps + `deleted_at`
(`Models/Milestone.php:29-58`). Não há relação `image()`; a relação é injetada
manualmente via `setRelation` nos controllers.

```mermaid
erDiagram
  v2_historia_milestones {
    uuid id PK
    varchar period
    varchar title
    text description
    varchar image_id
    int sort_order
    boolean is_active
    varchar created_by
    timestamp created_at
    timestamp updated_at
    timestamp deleted_at
  }
  v2_gallery_media ||..o{ v2_historia_milestones : "image_id (logico, sem FK)"
  v2_portal_configs ||..o{ v2_historia_milestones : "nao relacionado: SEO da pagina"
```

Escrita: apenas o controller CMS (`store`, `update`, `destroy`, `reorder`, `toggleActive`) e
o seeder. Leitura pública: `HistoriaPortalController`; leitura auxiliar: `SVC-DASHBOARD`
apenas para rótulo de `activity_log`, `SeoPortalController::addHistory` só verifica a
existência da tabela.

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Ativo: store (sort_order = max+1, is_active=true)
  Ativo --> Inativo: toggleActive (sort_order=-1, decrementa ativos > antiga)
  Inativo --> Ativo: toggleActive (sort_order=max(ativo)+1)
  Ativo --> Arquivado: destroy (deleted_at)
  Inativo --> Arquivado: destroy (deleted_at)
  Arquivado --> [*]
```

- `FL-HISTORIA-01` (criar): POST válido → `v2_uuid()` → `image_id` (opcional) →
  `nextSortOrder` → save → clear cache → `activity_log created` → redirect índice.
- `FL-HISTORIA-02` (editar): GET `edit` → `findOrFail` → imagem (se numérica) → view; POST/PUT
  → validação → atribuições → save → clear cache → `activity_log updated` → redirect edit.
- `FL-HISTORIA-03` (reordenar): clique → DOM move → `ids[]` → N updates → clear cache →
  JSON `{success:true}` → `location.reload()`.
- `FL-HISTORIA-04` (toggle): clique → POST → `is_active`/`sort_order` → clear cache →
  `activity_log updated` → JSON com mensagem → reload.
- `FL-HISTORIA-05` (arquivar): confirmação → DELETE → soft delete → clear cache →
  `activity_log deleted` → flash → índice.

## 8. UI CMS

Resumo (detalhe em [`cms.md`](cms.md)):

- **Listagem** `/cms/historia`: contador de registros, filtro de busca, tabela com Imagem
  (ícone apenas), Período (badge), Título (link para editar), Ordem (contador + botões ↑/↓),
  Ações (toggle, editar, arquivar com modal); paginação com query preservada; estado vazio
  distingue busca sem resultado de base vazia (`Views/cms/index.blade.php:28-142`).
- **Criação/Edição**: `_form` (período, título), CRE, `media-input`, preview (placeholder na
  criação, iframe na edição), botões Salvar/Atualizar e Cancelar (`Views/cms/create.blade.php:69-118`;
  `Views/cms/edit.blade.php:71-122`).
- **Configurações**: apenas `seo-input` com prefixo `historia_seo` e botão salvar
  (`Views/cms/config.blade.php:27-39`).
- **Preview**: documento HTML independente com banner de modo preview
  (`Views/cms/preview.blade.php:1-88`).

## 9. UI Portal

Resumo (detalhe em [`portal.md`](portal.md)): hero com breadcrumb, intro fixa, um `<section>`
por marco com cartão alternado, CTA final, `Article` JSON-LD, `page_context=historia`.
SEO: `title`, `description` e `keywords` vêm de `v2_portal_configs`
(`historia_seo_*`) com defaults no controller; o `pageTitle`/`pageDescription` literais da
view **não** vencem quando o `SEOService` está preenchido (`SEOServiceProvider.php:45-56`;
`metadata/seo.blade.php:1-11`). O composer usa `getTitle() ?: titulo_site`, e o `??` de
`metadata/seo.blade.php:2` curto-circuita a concatenação `pageTitle | siteTitle` sempre que
`$seoTitle` existe (inclusive o fallback `titulo_site`), então o `<title>` não duplica o
sufixo. `/historia` entra no sitemap com `priority 0.5`,
`changefreq monthly` (`SeoPortalController.php:339-350`).

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

**`ModuleProvider::getConfig()`** (`ModuleProvider.php:17-48`):

| Chave | Valor |
|---|---|
| `title` | `História` |
| `description` | `Linha do tempo com marcos históricos da organização.` |
| `icon` | `bi bi-clock-history` |
| `position` | `7` |
| `active_pattern` | `['cms/historia*']` |
| `route` | `v2.historia.cms.milestones.index` |
| `subModulesLevel1` | Marcos Históricos (`v2.historia.cms.milestones.index`, pos. 1), Novo Marco (`…create`, pos. 2), Configurações (`v2.historia.cms.config`, pos. 3) |

**Permissões**: RBAC por módulo — `permissions_v2['Historia'] ∈ {none,read,write}`;
`CmsAuthenticate` exige sessão Sentinel e ≥1 papel; `super-admin` e o e-mail de suporte
passam direto (`CmsAuthenticate.php:30-45`; `V2PermissionMiddleware.php:74-79`).

**Flags**: não há linha semeada em `v2_module_flags` para `Historia`; `ModuleFlagService`
considera módulo ativo quando a flag não existe (`ModuleFlagService.php:82-88`). Com
`active=false`, o grupo CMS é bloqueado (`module.active:Historia`).

**Config de aplicação/DB**: `historia_seo_title|description|keywords` (grupo `MODULOS`);
`cache.prefix` influencia o padrão de limpeza; `app.name` compõe o default
`Nossa História — {app.name}`.

## 11. Observabilidade, erros e limites

- **activity_log** (Spatie, via `ActivityLoggerService`): `created` (`historia_milestone_create`),
  `updated` (`historia_milestone_update`), `deleted` (`historia_milestone_delete`),
  `updated` (`historia_milestone_toggle`), `updated` (`historia_config_seo_update` — só se
  o log não quebrar). Todas as entradas incluem `v2_audit`, `ip_address`, `user_agent` e
  `transaction_snapshot_id` quando existir (`ActivityLoggerService.php:20-38`).
- **Logs**: `[MilestoneCmsController] Falha ao limpar cache de páginas: …` (warning),
  `[Milestone] Erro ao renderizar descrição: …` (error).
- **HTTP**: `404` em `findOrFail`; `302` para login/dashboard/permissão; `401`/`403` em
  AJAX; `500` no POST de configuração (`RN-HISTORIA-12`); `422` não é usado.
- **Limites**: listagem 20/página; sem throttle; sem limite de tamanho de `description`;
  `image_id` máx. 36; `ids[]` sem teto; cache de página 5 min.
- **Dados sensíveis**: nenhum no módulo; o conteúdo é público por definição.

## 12. Seeds e dados iniciais

O seeder pertence à unidade `DATA-SEEDS-BASICPAGES`, não a este módulo, mas escreve na
tabela dele:

- `HistoriaMilestonesSeeder` — transação única; processa 5 imagens
  (`assets/img/project/project-{1..5}.jpg`) via trait `ProcessesGalleryImages`; cria/atualiza
  5 marcos (1965-1967, 1968-1970, 1970-1990, “Sede em Brasília”, 2024) com AST JSON de
  parágrafos e `sort_order` 1..5; idempotência por `title` (primeiro registro encontrado é
  atualizado); `image_id` só é preenchido se o processamento da imagem devolver `id`
  (`V2/Seeds/BasicPages/HistoriaMilestonesSeeder.php:12-26,155-175`).
- **Não** há seed de `historia_seo_*`; a página nasce com os defaults do controller.
- A imagem de hero (`assets/sucesu/img/hero/breadcrumb.png`) é referenciada direto no
  Blade, não pela galeria (`Views/portal/index.blade.php:43,138`).

## 13. Lacunas

- `LG-HISTORIA-01` — Tipo real de `created_by`: a coluna é `varchar(36)`, mas o id Sentinel
  da tabela `users` é inteiro; não há FK nem leitura. Procurar em `app/Http/Model/Admin/User.php`
  e na migration de `users` para confirmar. Impacto: baixo (auditoria).
- `LG-HISTORIA-02` — Origem dos caminhos legados em `image_id`: não há migration de
  conversão, nem registro de quando o campo deixou de guardar path. Procurar em migrations
  antigas/`gallery` e no histórico. Impacto: médio (imagens invisíveis).
- `LG-HISTORIA-03` — Se `historia_seo_*` foi semeada em produção por script manual: não há
  seeder. Impacto: médio (SEO usa defaults até alguém rodar SQL).
- `LG-HISTORIA-04` — Comportamento do Redis sem prefixo configurado: `config('cache.prefix','laravel')`
  pode divergir do prefixo efetivo do store; não há teste. Impacto: baixo (cache stale).
- `LG-HISTORIA-05` — Efeito de `web` duplicado na pilha (sessão/CSRF duas vezes) não foi
  exercitado; não há teste de integração das rotas do módulo. Impacto: baixo.
- `LG-HISTORIA-06` — Não há tela/rota para restaurar marcos arquivados: o soft delete só é
  reversível por SQL. Impacto: médio (operação).

## 14. Checklist de paridade

- [ ] `PC-HISTORIA-01` — `GET /historia` lista somente `is_active=true`, ordenado por
      `sort_order ASC, created_at ASC`, com um cartão por marco e imagem alternada.
- [ ] `PC-HISTORIA-02` — Nenhum marco: bloco “Em breve / Nossa linha do tempo está sendo construída.”.
- [ ] `PC-HISTORIA-03` — Criar marco grava UUID v4, `created_by`, `sort_order=max+1` e
      `is_active=true`; redireciona ao índice com flash `created_item`.
- [ ] `PC-HISTORIA-04` — Validação: `period required|max:100`, `title required|max:255`,
      `description nullable|string`, `image_id nullable|max:36`, `sort_order integer|min:0`.
- [ ] `PC-HISTORIA-05` — Editar preserva `sort_order`/`is_active` quando ausentes e
      redireciona para a própria edição com `Marco histórico atualizado com sucesso!`.
- [ ] `PC-HISTORIA-06` — Arquivar é soft delete, flash `Marco histórico "<título>" arquivado com sucesso!`.
- [ ] `PC-HISTORIA-07` — Reorder atualiza `sort_order=índice+1` para todos os ids enviados e
      responde `{"success":true}`; erro de AJAX mostra `Erro ao reordenar. Tente novamente.` e recarrega.
- [ ] `PC-HISTORIA-08` — Toggle desativa com `sort_order=-1` + decremento dos ativos acima;
      reativa com `max(ativo)+1`; JSON com `message` `Marco "<título>" ativado|desativado com sucesso!`.
- [ ] `PC-HISTORIA-09` — Configuração lê/grava as 3 chaves `historia_seo_*` no grupo
      `MODULOS`; o comportamento de erro de auditoria (`500`) deve ser decidido na
      reimplementação (corrigir ou reproduzir) — registrar a escolha.
- [ ] `PC-HISTORIA-10` — Preview é documento isolado com banner “Modo Preview — este conteúdo não está publicado”.
- [ ] `PC-HISTORIA-11` — `getHtmlDescription()` renderiza AST válido e devolve `''` para
      vazio/JSON inválido; nunca imprime o JSON cru.
- [ ] `PC-HISTORIA-12` — `image_id` não numérico/inexistente não gera erro e não renderiza imagem.
- [ ] `PC-HISTORIA-13` — Toda mutação limpa `page:*` e registra `activity_log` com os tipos
      `historia_milestone_create|update|delete|toggle`.
- [ ] `PC-HISTORIA-14` — Sitemap contém `/historia` (`priority 0.5`, `changefreq monthly`).
- [ ] `PC-HISTORIA-15` — Sem permissão `read`, o grupo CMS redireciona ao dashboard com
      `Acesso negado. Seu perfil não possui permissão para acessar o módulo: História`.

## 15. Evidências consultadas

- `V2/Modules/Institucional/Historia/Routes/cms.php:1-61`; `Routes/web.php:1-10`.
- `V2/Modules/Institucional/Historia/ModuleProvider.php:1-49`.
- `V2/Modules/Institucional/Historia/Models/Milestone.php:1-98`.
- `V2/Modules/Institucional/Historia/Migrations/2026_07_20_000001_create_v2_historia_milestones_table.php:1-61`.
- `V2/Modules/Institucional/Historia/Controllers/HistoriaPortalController.php:1-95`.
- `V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:1-378`.
- `V2/Modules/Institucional/Historia/Views/cms/index.blade.php:1-215`; `create.blade.php:1-118`;
  `edit.blade.php:1-122`; `_form.blade.php:1-27`; `config.blade.php:1-40`;
  `preview.blade.php:1-89`; `Views/portal/index.blade.php:1-157`.
- `V2/Core/Providers/V2ServiceProvider.php:96-134,203-224,293-307`; `V2/Core/Middleware/CmsAuthenticate.php:1-46`;
  `V2/Core/Middleware/V2PermissionMiddleware.php:1-137`; `V2/Core/Middleware/ModuleAccessMiddleware.php:1-67`;
  `V2/Core/Middleware/PageCacheMiddleware.php:1-91`; `V2/Core/Middleware/PublicCacheMiddleware.php:1-95`.
- `V2/Services/ActivityLogger/ActivityLoggerService.php:1-53`;
  `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:1-36`;
  `V2/Services/SEO/Providers/SEOServiceProvider.php:1-70`;
  `V2/Services/SEO/Views/components/seo-input.blade.php:1-170`; `V2/layouts/portal/metadata/seo.blade.php:1-50`.
- `V2/Services/SEO/Controllers/SeoPortalController.php:325-355,35-55`;
  `V2/Services/Dashboard/DashboardStatsService.php:93-105,660-675`;
  `V2/Seeds/BasicPages/HistoriaMilestonesSeeder.php:1-183`.
- `sucesu_analise/03_servicos-ui/seo/contrato.md:155-175,314,420-445`.
