---
id: MOD-LAYOUTS-BASICPAGE
tipo: modulo
onda: 3
status: concluido
unidades: [MOD-LAYOUTS-BASICPAGE]
fontes:
  - V2/Modules/Layouts/BasicPage/
depende_de: [CORE-BOOT, CORE-CMS, CORE-AUTH, SVC-GALLERY, SVC-MEDIA, SVC-STORAGE, SVC-SEO, SVC-SEARCH, SVC-ACTIVITYLOGGER, UI-CMS, UI-PORTAL]
consumidores: [CORE-BOOT, SVC-SEO, SVC-SEARCH, SVC-DASHBOARD, SVC-MEDIA, DATA-SEEDS-BASICPAGES, MOD-LAYOUTS-HOMEPAGE]
criticidade: alta
dono: PageBasicPage
---

# Layouts/BasicPage

> Duas coisas no mesmo módulo: (a) a **File Library** de CSS/JS/imagem usada pelo editor de páginas e (b) o **editor runtime** de HTML/CSS livre que, quando há `content`, substitui as cinco páginas institucionais em Blade. Não é um construtor de seções — é um `<textarea>` de código com controle de layout.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Grava `v2_basic_pages.content/content_type/metadata` e reindexa a página na busca; no portal, troca a view Blade fixa por `content` cru quando ele não está vazio. |
| Entrada | 15 rotas CMS sob `/cms/paginas-fixas` (9 `GET`, 6 `POST`); 4 rotas públicas fixas + o catch-all `GET /{slug}`; `<input type=file>` de até 10240 KB; `id` de `v2_gallery_media`. |
| Saída | HTML no layout `v2-cms::layouts.app` / `v2-portal::app-layout`; fragmento HTML de cards de asset; JSON `{success,asset,snippet}`; `302` com flash; `403`/`404`; linhas em `v2_basic_pages`, `v2_basic_page_assets`, `activity_log`, `v2_public_search_index`, `v2_cms_links_index`; arquivos em `assets/basicpage/{css,js}/`. |
| Efeitos colaterais | Auditoria (`basicpage_runtime_save`, `basicpage_runtime_create`, `created`/`deleted` de asset), `strip_tags(content)` enviado aos dois índices de busca, `JSON-LD` no `<head>`, `lastmod` no sitemap via `updated_at`, cache Redis `page:*` de 5 min **não invalidado**. |
| Particularidade central | O conteúdo runtime é **código-fonte HTML digitado no CMS** e ecoado com `{!! !!}` (`Views/portal/runtime.blade.php:4`); não há blocos, seções, widgets, versionamento nem preview renderizado — o “preview” é um `<iframe>` da própria URL pública. |
| Como se integra | Namespace de views `v2-basicpage`; classes `V2\Modules\Layouts\BasicPage\{Models\BasicPage,Models\BasicPageAsset,Services\BasicPageAssetManagerService}`; chave de permissão `BasicPage`; `config('v2.support.email')`; `@include('v2-service-gallery::components.gallery-picker')`. |
| Se removido, quebra | Catch-all `/{slug}` (404 em qualquer slug), `/lgpd`, `/acessibilidade`, `/mapa-do-site`, `/ouvidoria`, menu “Páginas do Portal”, sitemap (`SeoPortalController::addBasicPages`), reindexação `v2:reindex-public --only=...`, card “Nova Página” do dashboard, seeders `V2/Seeds/BasicPages/*` (`createFromGallery`) e `RedigestImages` (tabelas `v2_basic_pages`/`v2_basic_page_assets`). |
| Superfície pública | `BasicPageCmsController@{index,edit,saveContent,create,store,preview*,updateSeo}`, `BasicPageRuntimeController@{assets,assetsUpload,assetsFromGallery,assetsDelete}`, `BasicPagePortalController@{lgpd,acessibilidade,mapaSite,ouvidoria,runtime}`, `BasicPage::{getLayoutConfig,getSeoData,syncToSearch}`, `BasicPageAssetManagerService::{uploadVendor,uploadImage,listAssets,deleteAsset,createFromGallery,seedFromPath,generateSnippetForGallery}`. |

## 1. Efeito

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

- **Suporte (`config('v2.support.email')`)** abre `/cms/paginas-fixas/editor/{slug}`, edita HTML/CSS em CodeMirror, liga/desliga 9 flags de layout (bloco principal e elementos do portal), sobe CSS/JS/imagens na barra lateral e salva. Recebe `Conteúdo de "<título>" salvo com sucesso!` e volta ao editor (`BasicPageCmsController.php:170-171`).
- **Suporte** cria página nova em `/cms/paginas-fixas/editor/create`; ela nasce `is_runtime = true`, com `metadata.seo.title = title` e aparece imediatamente em `/cms/paginas-fixas` e em `GET /{slug}` (`BasicPageCmsController.php:217-225`).
- **Qualquer perfil com `BasicPage: write`** abre o modal “SEO” na listagem ou no preview e grava `metadata.seo` (`BasicPageCmsController.php:285-327`). Recebe `Metadados de SEO atualizados com sucesso!`.
- **Qualquer perfil com `BasicPage: read`** lista todas as linhas de `v2_basic_pages` com badge `Runtime` (tem `content`) ou `Fallback` (sem), link “Ver” para o portal e badges “Fixa/Runtime” no editor (`Views/cms/index.blade.php:53-58,63-75`).
- **Cidadão** vê `/lgpd`, `/acessibilidade`, `/mapa-do-site`, `/ouvidoria` (Blade fixo ou `content` runtime, conforme o banco) e qualquer `slug` de página runtime criada no CMS. `/` é do módulo Homepage, não daqui (`Routes/web.php:8`).
- **CMS (somente leitura no código, não na tela)** consome o fragmento `/cms/paginas-fixas/editor/assets?type=css|js|image` para montar a barra lateral (`Views/cms/components/asset-sidebar.blade.php:180-201`).

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| Cria/atualiza linha em `v2_basic_pages` e dispara `saved → syncToSearch()` | `BasicPage::create/save` | `Models/BasicPage.php:136-144` |
| Upsert em `v2_public_search_index` com `module='basicpage'`, `entity_type` = FQCN do model | `PublicIndexerService::index` | `Models/BasicPage.php:240-253`; `V2/Services/Search/Services/PublicIndexerService.php:22-57` |
| Upsert em `v2_cms_links_index` com `edit_url` de `/cms/paginas-fixas/editor/{slug}` | `CMSIndexerService::index` | `Models/BasicPage.php:225-237` |
| Arquivo físico em `assets/basicpage/css\|js/<uniqid>_<time>.<ext>` (visibilidade pública) | `uploadVendor` | `Services/BasicPageAssetManagerService.php:26-38` |
| Linha em `v2_basic_page_assets` (soft delete) com `snippet` pronto para copiar | `uploadVendor`/`uploadImage`/`createFromGallery` | `Services/BasicPageAssetManagerService.php:42-49,70-77,117-125` |
| Imagem passa a existir também em `v2_gallery_media` (`owner_module='basicpage'`, `is_shared=false`) | `GalleryService::upload` chamado por `uploadImage` | `Services/BasicPageAssetManagerService.php:54-64` |
| `alt_text` e `description` da mídia recebem o `original_name` quando não informados | `GalleryService::upload` | `V2/Services/Gallery/Services/GalleryService.php:65-73` |
| Auditoria em `activity_log` | `ActivityLoggerService::log` | `Controllers/BasicPageCmsController.php:160-168,227-235`; `BasicPageRuntimeController.php:62-72,128-141` |
| Remoção do arquivo físico (best-effort) + soft delete do asset | `deleteAsset` | `Services/BasicPageAssetManagerService.php:89-100` |
| Página entra no sitemap com `priority 1.0/daily` (homepage) ou `0.6/monthly` | `SeoPortalController::addBasicPages` | `V2/Services/SEO/Controllers/SeoPortalController.php:149-172` |
| Cache de página (`page:<sha1(fullUrl)>`, Redis, 5 min) passa a servir a versão antiga | `PageCacheMiddleware` nas rotas web/fallback | `V2/Core/Middleware/PageCacheMiddleware.php:25-59` |
| Nada é invalidado no cache ao salvar | ausência de `Cache::forget` no módulo | `grep` no diretório: nenhuma ocorrência |

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

| Entrada | Resultado |
|---|---|
| `GET /cms/paginas-fixas` autenticado | `200` com todas as linhas da tabela; `portal_url` resolvido por mapa fixo (`homepage→v2.portal.home`, etc.), por `is_runtime` → `v2.portal.runtime`, senão por `v2.portal.<slug_com_underscore>` se existir, ou `null` (`BasicPageCmsController.php:63-96`). |
| `GET /cms/paginas-fixas` sem sessão | `302 v2.cms.login` (ou `401 Unauthorized.` em AJAX), via `CmsAuthenticate` (`V2/Core/Middleware/CmsAuthenticate.php:20-26`). |
| `GET .../editor/{slug}` com slug inexistente | `404` (`firstOrFail`, `BasicPageCmsController.php:109`). |
| `GET .../editor/{slug}` autenticado sem ser suporte | `403 Acesso restrito ao suporte técnico autorizado.` |
| `POST .../editor/save` com `slug` inexistente | `302` com erro de validação `The selected slug is invalid.` |
| `POST .../editor/save` válido | `302 v2.basicpage.editor.edit?slug=...`, flash `success`; `metadata.layout` é **substituído** por inteiro (chaves não enviadas desaparecem) (`:148-156`). |
| `POST .../editor/store` slug repetido | `302` com `The slug has already been taken.`; slug com `_`/espaço → `The slug may only contain letters, numbers, and dashes.` |
| `POST .../editor/store` válido | `302 v2.basicpage.index` + flash `created_item` (`type=Página`); `is_runtime=true`; **`metadata` é gravado duas vezes em JSON** (ver §3.5). |
| `POST .../seo/update` sem `BasicPage: write` | `V2PermissionMiddleware` responde **antes** do controller: `302` de volta com `Operação negada. Seu perfil possui apenas permissão de leitura para o módulo: Páginas do Portal` (HTML) ou `403 Forbidden. Write actions are not allowed for your profile.` (AJAX). O `403 Acesso negado. Ação restrita a operadores com permissão de escrita.` do controller (`:287-289`) é inalcançável sem `write`. |
| `POST .../seo/update` sem `seo.title` | `302` com `The seo.title field is required.` |
| `POST .../editor/assets/upload` `file` > 10 MB | `302`/`422` com erro de `max`; o tipo é aceito apenas `css\|js\|image`. |
| `POST .../editor/assets/upload` AJAX válido | `200 {success:true,asset:{...},snippet:"..."}`; em falha `422 {success:false,message}`. |
| `POST .../editor/assets/from-gallery` id inexistente | `422 {success:false,message:"Midia nao encontrada na galeria (ID: N)"}` |
| `POST .../editor/assets/from-gallery` id já vinculado | `200` com o **asset existente** (idempotente, não duplica). |
| `POST .../editor/assets/delete/{id}` | `302 v2.basicpage.index`, flash `Asset "<nome>" removido com sucesso!`; id inválido → flash `error` com a mensagem da exceção. |
| `GET /{slug}` casando `[a-z0-9-]+` e existindo com `is_runtime=true` | `200` view `portal.runtime` com `content` cru. |
| `GET /{slug}` sem linha runtime | `404` (`firstOrFail`). |
| `GET /{slug}` com maiúscula/acento | `404` sem consultar o banco (regex da rota). |
| `GET /lgpd` com `content` não vazio | renderiza `portal.runtime` (o Blade fixo é ignorado). |
| `GET /lgpd` com `content` vazio/nulo | renderiza `portal.lgpd` com layout e textos hardcoded (`Views/portal/lgpd.blade.php:1-13`). |

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

- **Não constrói seções/blocos**: não há tabela de seções, ordem, widgets, nem editor visual. Conteúdo = string única.
- **Não compila Blade**: `{!! $page->content !!}` (`Views/portal/runtime.blade.php:4`); `@include`, `{{ }}` e `@if` no conteúdo **não** são interpretados.
- **Não faz upload de arquivo para dentro do conteúdo**: assets são um repositório global; nada é injetado automaticamente no HTML — o snippet é copiado à mão (ou nem isso, ver §3.4).
- **Não versiona**: `activity_log` guarda `old`/`new` do `content` em JSON, mas não há restore nem snapshot por id. O preview não renderiza; ele abre `iframe` da URL pública.
- **Não apaga página**: nenhuma rota de `destroy` de `v2_basic_pages`.
- **Não publica/despublica nem agenda**; `is_runtime` é o único discriminador e é definido na criação.
- **Não invalida cache** de página nem de sitemap.
- **Não autentica sozinho**: usa `CmsAuthenticate` → `V2PermissionMiddleware` (CMS) e nenhum auth no portal.

## 2. Abordagem

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

O módulo reaproveita uma única tabela como “overlay” das páginas institucionais: cinco linhas semeadas na migration (`homepage`, `lgpd`, `acessibilidade`, `mapa-do-site`, `ouvidoria`). Cada rota pública fixa carrega a linha correspondente, aplica SEO e decide entre o Blade histórico e um renderizador genérico que só ecoa `content`. O catch-all `/{slug}` usa a mesma decisão, exigindo `is_runtime = true`. O editor é um formulário POST com um CodeMirror (`htmlmixed|css|javascript`) e 9 switches; os assets vivem em tabela separada, sem FK para a página, e produzem um “snippet” textual para colar no conteúdo.

```mermaid
flowchart LR
  A[GET /lgpd ou /{slug}] --> B[BasicPage::where slug]
  B -->|content vazio| C[view fixa portal.lgpd]
  B -->|content preenchido| D[portal.runtime + {!! content !!}]
  B --> E[getSeoData] --> F[SEOService setTitle/Description/Keywords/Cover/Schema]
  G[POST editor/save] --> H[validate slug exists + flags] --> I[save] --> J[saved hook] --> K[Public/CMS indexer]
  L[POST assets/upload] --> M[StorageService ou GalleryService] --> N[v2_basic_page_assets.snippet]
```

### 2.2 Mecânica passo a passo

1. **Boot**: `V2ServiceProvider` instancia todo `ModuleProvider` e registra `BasicPageAssetManagerService` como singleton (`ModuleProvider.php:10-19`); carrega migrations, views `v2-basicpage` e rotas `Routes/web.php` com `web + MaintenanceMiddleware + page.cache + public.cache`, e `Routes/cms.php` com `web + module.active:BasicPage` (`V2/Core/Providers/V2ServiceProvider.php:289-307`).
2. **Listagem**: `index()` faz `BasicPage::all()->keyBy('slug')` — sem paginação, sem escopo, sem filtro — e resolve a URL pública por três tentativas (`BasicPageCmsController.php:45-96`). Não chama `authorizeSupportAccess`.
3. **Edição**: `edit($slug)` valida que o e-mail é `config('v2.support.email')` e faz `firstOrFail` (`:106-115`); a view monta CodeMirror (`v2-cms::partials.code-editor`) + switches a partir de `getLayoutConfig()` (`Views/cms/editor.blade.php:68-155`).
4. **Salvar conteúdo**: valida `slug` existente, `content_type ∈ {htmlmixed,css,javascript}`, e cada uma das 9 flags em `{0,1,true,false}` (`:122-136`); copia `content/content_type`; converte flags com `filter_var(..., FILTER_VALIDATE_BOOLEAN)` e **recria** `metadata['layout']` (`:148-156`); `save()` dispara o hook de busca (§2.4-1); audita `old`/`new`; redireciona ao editor.
5. **Criar página**: valida `slug required|alpha_dash|unique|max:100` e `title max:255`, monta `metadata['seo']` e `metadata['layout']`, e cria com `is_runtime=true` e `id=v2_uuid()` (`:191-225`). Efeito colateral: hook de busca indexa a página.
6. **SEO**: valida `slug exists`, `seo.title required|max:255`, `description max:500`, `keywords max:255`, `json_ld/image_id/image_url` strings (`:291-299`); grava o array em `metadata['seo']` sem mesclar com o valor anterior (campos omitidos viram `null`); audita em `try/catch` que só loga warning (`:303-323`).
7. **Upload de asset**: valida `file required|file|max:10240` e `type required|in:css,js,image` (`BasicPageRuntimeController.php:47-50`); para `image` delega ao Gallery (`uploadImage`), senão grava direto via `StorageService::put(..., ['visibility'=>'public'])` (`BasicPageAssetManagerService.php:26-38`); cria a linha com `snippet` gerado; audita; responde JSON em AJAX ou redireciona.
8. **Galeria**: `createFromGallery` busca `v2_gallery_media` por id, retorna o asset existente se `gallery_media_id` já vinculado, senão copia `file_path/mime_type/file_size/alt_text/variants` e gera o snippet (`BasicPageAssetManagerService.php:102-125`).
9. **Exclusão**: `deleteAsset` faz `deleteSafe` do arquivo (warning se falhar) e `delete()` (soft delete); o controller audita `deleted` com os dados antigos (`BasicPageRuntimeController.php:121-146`).
10. **Portal**: `lgpd/acessibilidade/mapaSite/ouvidoria` carregam por slug fixo, chamam `bindBasicPage` (SEO) e `renderWithRuntimeFallback`; se `content` não vazio, devolvem `portal.runtime` com `array_merge([], getLayoutConfig())`, senão deixam o controller devolver a view fixa (`BasicPagePortalController.php:88-144`). `runtime($slug)` exige `is_runtime=true` (`:147-158`).

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

| Decisão | Observável | Marca |
|---|---|---|
| Uma tabela para páginas fixas e runtime, discriminadas por `is_runtime` | `v2_basic_pages.is_runtime` default `false`; `store()` grava `true` | `[C]` `Migrations/2026_06_29_092300_create_basic_pages_table.php:30`; `BasicPageCmsController.php:223` |
| O conteúdo substitui a view inteira, não um slot | quando há `content`, a view fixa nem é renderizada | `[C]` `BasicPagePortalController.php:56-68,88-95` |
| Layout por página em `metadata['layout']`, com merge `app defaults < slug defaults < banco` | homepage tem default `fullWidthMain=true` e todos os cromados `false` | `[C]` `Models/BasicPage.php:150-190` |
| SEO em `metadata['seo']` com defaults por slug embutidos no model | 11 slugs têm título/description/keywords/JSON-LD de fábrica | `[C]` `Models/BasicPage.php:32-134,199-217` |
| Assets globais (sem `page_id`) | nenhuma coluna de página em `v2_basic_page_assets`; nenhuma tabela pivô | `[C]` migration de assets `:18-29` |
| Imagens sempre via Gallery; CSS/JS nunca | `uploadImage` usa `GalleryService`; `uploadVendor` usa `StorageService` | `[C]` `BasicPageAssetManagerService.php:26-77` |
| Snippet é texto gravado no banco, não gerado na leitura | coluna `snippet` persistida | `[C]` `Migrations/...assets...:26`; `BasicPageAssetManagerService.php:40,115` |
| Rotas de asset declaradas antes de `/editor/{slug}` | comentário explícito no arquivo | `[C]` `Routes/cms.php:22-28` |
| Catch-all movido para o Core, registrado por último | comentário em `Routes/web.php:16-21` e `fallback.php:7-12` | `[C]` |
| Indexação síncrona no `saved`, sem fila e sem hook `deleted` | `static::saved`; nenhum `deleted` | `[C]` `Models/BasicPage.php:136-144` |
| Boot faz “catch-up” de páginas não indexadas só em console | `runningInConsole()` + `Schema::hasTable` | `[C]` `V2/Core/Providers/V2ServiceProvider.php:145-166` |

### 2.4 Trechos de código pivô

```php
// V2/Modules/Layouts/BasicPage/Models/BasicPage.php:136-144 — todo save reindexa, sem fila
protected static function boot()
{
    parent::boot();

    // Salva e sincroniza com o motor de busca global automaticamente no evento saved
    static::saved(function ($model) {
        $model->syncToSearch();
    });
}
```

O hook é a única sincronização automática. Ele roda em `create`, `save` e em qualquer `update` de atributo — inclusive quando só o SEO muda. Não existe `deleted`, então apagar uma linha deixaria o índice órfão (não há rota de delete, mas seeds que apagam por `DB::table` também não disparam nada).

```php
// V2/Modules/Layouts/BasicPage/Models/BasicPage.php:169-190 — precedência de layout
public function getLayoutConfig()
{
    $stored = isset($this->metadata['layout']) ? $this->metadata['layout'] : [];
    $defaults = isset(static::$layoutDefaults[$this->slug])
        ? static::$layoutDefaults[$this->slug]
        : [];

    return array_merge(
        ['fullWidthMain' => false, 'showHeader' => true, 'showMainHeader' => true,
         'showMainFooter' => true, 'showFooter' => true, 'showAccessibilityBar' => false,
         'stickyAccessibilityBar' => false, 'showMainLeftArea' => false, 'showMainRightArea' => false],
        $defaults,
        $stored
    );
}
```

A lista literal foi abreviada na citação (o original tem uma chave por linha). O merge é raso: qualquer chave desconhecida vinda do banco é propagada ao layout e vira variável de view; qualquer chave ausente no banco cai no default do slug. `homepage` é o único slug com defaults próprios (`BasicPage.php:150-162`).

```php
// V2/Modules/Layouts/BasicPage/Models/BasicPage.php:199-217 — SEO com três camadas
public function getSeoData()
{
    $stored = isset($this->metadata['seo']) ? $this->metadata['seo'] : [];
    $defaults = isset(static::$seoDefaults[$this->slug]) ? static::$seoDefaults[$this->slug] : [];

    // Preenche a URL base do JSON-LD dinamicamente
    if (isset($defaults['json_ld'])) {
        $defaults['json_ld']['url'] = url('/') . ($this->slug === 'homepage' ? '' : '/' . $this->slug);
    }

    return [
        'title'       => !empty($stored['title'])       ? $stored['title']       : ($defaults['title'] ?? $this->title),
        'description' => !empty($stored['description']) ? $stored['description'] : ($defaults['description'] ?? ''),
        'keywords'    => !empty($stored['keywords'])    ? $stored['keywords']    : ($defaults['keywords'] ?? ''),
        'image_url'   => !empty($stored['image_url'])   ? $stored['image_url']   : '',
        'image_id'    => !empty($stored['image_id'])    ? $stored['image_id']    : '',
        'json_ld'     => !empty($stored['json_ld'])     ? $stored['json_ld']     : ($defaults['json_ld'] ?? null),
    ];
}
```

Cada campo é decidido individualmente (não é merge de arrays): `''` conta como ausente (`empty`), logo não é possível “apagar” um default de título/description/keywords pelo CMS — só sobrescrever. `image_url` e `image_id` não têm default e ficam vazios. O `json_ld` default recebe `url` calculada a partir do slug (não do request).

```php
// V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:145-158 — gravação do conteúdo
$page->content = $request->input('content', '');
$page->content_type = $request->input('content_type', 'htmlmixed');

$layoutInput = $request->input('layout');
if (is_array($layoutInput)) {
    $metadata = $page->metadata ?: [];
    $metadata['layout'] = [];
    foreach ($layoutInput as $key => $val) {
        $metadata['layout'][$key] = filter_var($val, FILTER_VALIDATE_BOOLEAN);
    }
    $page->metadata = $metadata;
}

$page->save();
```

`metadata['layout']` é sempre reconstruído quando o POST traz `layout` (o formulário manda dois inputs por flag: hidden `0` + checkbox `1`). `metadata['seo']` é preservado. `content_type` é apenas metadado de edição: nenhum ponto do render o consulta.

```php
// V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:208-225 — criação de página runtime
$metadata = ['seo' => ['title' => $request->input('title'), 'description' => '', 'keywords' => '']];
$layoutInput = $request->input('layout');
if (is_array($layoutInput)) {
    $metadata['layout'] = [];
    foreach ($layoutInput as $key => $val) {
        $metadata['layout'][$key] = filter_var($val, FILTER_VALIDATE_BOOLEAN);
    }
}

$page = BasicPage::create([
    'id'           => v2_uuid(),
    'slug'         => $request->input('slug'),
    'title'        => $request->input('title'),
    'content'      => $request->input('content', ''),
    'content_type' => $request->input('content_type', 'htmlmixed'),
    'is_runtime'   => true,
    'metadata'     => json_encode($metadata),
]);
```

Aqui está a armadilha central: `metadata` está no `$casts` como `array` (`BasicPage.php:22-24`), então o Eloquent aplica `json_encode` **de novo**. O valor nasce duplamente codificado; ver §3.5 e §3.3. `id` é gerado por `v2_uuid()` (UUID v4, `V2/Core/Helpers/helpers.php:61-69`), não pelo banco.

```php
// V2/Modules/Layouts/BasicPage/Services/BasicPageAssetManagerService.php:31-49 — CSS/JS vão direto ao storage
$subdir = $type === 'css' ? 'css' : 'js';
$filename = $customFilename
    ? $customFilename . '.' . $extension
    : uniqid('vendor_') . '_' . time() . '.' . $extension;
$storagePath = 'assets/basicpage/' . $subdir . '/' . $filename;

$contents = file_get_contents($file->getRealPath());
$this->storageService->put($contents, $storagePath, ['visibility' => 'public']);

$snippet = $this->generateVendorSnippet($type, $storagePath);

$asset = BasicPageAsset::create([
    'type'          => $type,
    'original_name' => $originalName,
    'storage_path'  => $storagePath,
    'mime_type'     => $file->getMimeType(),
    'file_size'     => $file->getSize(),
    'snippet'       => $snippet,
]);
```

O caminho é derivado do `type` do request, a extensão vem do nome original e o conteúdo é lido inteiro em memória (`file_get_contents`) — sem streaming. Não há verificação de MIME/extensão coerente com `type`: um `.php` enviado como `css` é gravado em `assets/basicpage/css/` com visibilidade pública. `original_name` é preservado apenas como rótulo.

```php
// V2/Modules/Layouts/BasicPage/Services/BasicPageAssetManagerService.php:102-125 — galeria como fonte, idempotente
public function createFromGallery($galleryMediaId, $altText = null)
{
    $media = DB::table('v2_gallery_media')->find($galleryMediaId);
    if (!$media) {
        throw new \Exception('Midia nao encontrada na galeria (ID: ' . $galleryMediaId . ')');
    }

    $existing = BasicPageAsset::where('gallery_media_id', $galleryMediaId)->first();
    if ($existing) {
        return $existing;
    }

    $variants = json_decode($media->variants, true) ?: [];
    $snippet = $this->generateImageSnippet($media->file_path, $variants, $altText ?: $media->alt_text);

    return BasicPageAsset::create([
        'type'             => 'image',
        'original_name'    => $media->alt_text ?: basename($media->file_path),
        'storage_path'     => $media->file_path,
        'gallery_media_id' => (int) $galleryMediaId,
        'snippet'          => $snippet,
    ]);
}
```

O asset de imagem é uma **referência** à mídia da galeria: `storage_path` = `file_path` da galeria, `mime_type`/`file_size` copiados, `variants` não é copiado (só usado no snippet). O dedupe é por `gallery_media_id` (sem unique no banco, então duas requisições concorrentes podem criar duas linhas). O trecho omite `mime_type`/`file_size` na citação por espaço; estão em `:121-122`.

```php
// V2/Modules/Layouts/BasicPage/Routes/cms.php:22-29 — ordem de rotas é contrato
// ── File Library (must come BEFORE /editor/{slug}) ────────────────────
Route::get('/editor/assets', 'BasicPageRuntimeController@assets')->name('v2.basicpage.assets');
Route::post('/editor/assets/upload', 'BasicPageRuntimeController@assetsUpload')->name('v2.basicpage.assets.upload');
Route::post('/editor/assets/from-gallery', 'BasicPageRuntimeController@assetsFromGallery')->name('v2.basicpage.assets.from-gallery');
Route::post('/editor/assets/delete/{id}', 'BasicPageRuntimeController@assetsDelete')->name('v2.basicpage.assets.delete');

Route::get('/editor/{slug}', 'BasicPageCmsController@edit')->name('v2.basicpage.editor.edit');
Route::post('/editor/save', 'BasicPageCmsController@saveContent')->name('v2.basicpage.editor.save');
```

Se `/editor/{slug}` fosse declarada antes, `GET /editor/assets` casaria `{slug}=assets` e abriria o editor de uma página inexistente (404). Reimplementações com roteamento automático precisam fixar essa precedência.

### 2.5 Algoritmos, consultas e custos

- `BasicPage::all()->keyBy('slug')` (`:45`): full scan sem paginação; custo O(n) com n = linhas da tabela (5 da migration + ~6 dos seeders). A listagem não usa índice.
- `firstOrFail` por `slug`: usa `unique` de `v2_basic_pages.slug` (B-tree).
- `BasicPageAsset::orderBy('created_at','desc')` com filtro opcional `where type` (`BasicPageAssetManagerService.php:78-86`): sem índice em `type` nem em `created_at`; a sidebar dispara **3 GETs** (um por tipo) a cada abertura do editor (`asset-sidebar.blade.php:262-266`), e mais 3 após cada upload (`:269-277`).
- Snippet de imagem: `ImageRedigestService::buildPictureSnippet($filePath, $variants, $alt)` monta um `@include('v2-service-media::components.picture', [...])` com `variants` como literal PHP (`V2/Services/Media/Services/ImageRedigestService.php:38-70`).
- Busca: duas escritas por `save()` (uma por indexador), cada uma um `INSERT ... ON CONFLICT (entity_type, entity_id) DO UPDATE` (`PublicIndexerService.php:26-50`); `search_deep_content` recebe `strip_tags(content)` — o HTML inteiro vira texto indexável.
- Sitemap: um `SELECT slug, updated_at` de todas as linhas por geração (`SeoPortalController.php:155-157`).
- Portal: 1 query por request não cacheado; com cache HIT, 0 queries.

### 2.6 Contrato de `BasicPageAssetManagerService`

Serviço singleton (`ModuleProvider.php:10-19`), sem estado, dependente de `StorageService` e `GalleryService`.

| Método | Entrada | Persistência | `type` | `snippet` gerado | Ordem / herança |
|---|---|---|---|---|---|
| `uploadVendor(UploadedFile $file, $type, $customFilename = null)` | arquivo + `css\|js` | `StorageService::put` em `assets/basicpage/{css,js}/<nome>.<ext>`, visibilidade `public` + linha | `css`/`js` | `<link rel="stylesheet" href="{{ asset('storage/…') }}">` ou `<script src="{{ asset('storage/…') }}"></script>` | nome = `customFilename.ext` ou `vendor_<uniqid>_<time>.ext`; **não** passa pelo Gallery |
| `uploadImage(UploadedFile $file, $altText = null)` | arquivo de imagem | `GalleryService::upload(file, GalleryConfig('basicpage', ['image/*'], 10240, false), alt)` → `v2_gallery_media` + variantes; linha referencia `gallery_media_id` | `image` | `ImageRedigestService::buildPictureSnippet(file_path, variants, alt)` → `@include('v2-service-media::components.picture', …)` | herda `file_path`, `variants` e `alt_text` da galeria; `is_shared=false` |
| `createFromGallery($galleryMediaId, $altText = null)` | id inteiro | nenhum arquivo novo; copia `file_path/mime_type/file_size` da linha da galeria | `image` | idem, com `altText ?: media.alt_text` | dedupe por `gallery_media_id`; não copia `variants` para a coluna |
| `listAssets($type = null)` | `css\|js\|image` ou nulo | leitura | — | — | `orderBy('created_at','desc')`; tipo inválido é ignorado (lista todos) |
| `deleteAsset($id)` | uuid | `StorageService::deleteSafe(storage_path)` + soft delete | — | — | falha física só gera warning |
| `seedFromPath($localFilePath, $type, $altText = null)` | caminho local | delega a `uploadVendor` (css/js, nome = basename) ou `uploadImage` (imagem) | `css\|js\|image` | conforme o delegado | usado por seeders; valida existência/leitura e lança `InvalidArgumentException("Arquivo local não encontrado ou ilegível: …")` |
| `generateSnippetForGallery($galleryMediaId)` | id inteiro | nenhuma | `image` | reusa o asset existente ou recalcula da galeria | retorna `<!-- Midia nao encontrada -->` quando o id não existe |

**Dependências e o que quebra sem elas**

| Dependência | Uso | Sem ela |
|---|---|---|
| `SVC-GALLERY` (`GalleryService`, `GalleryConfig`, `v2_gallery_media`, componente `v2-service-gallery::components.gallery-picker`) | upload de imagem, picker da sidebar, `createFromGallery` | `uploadImage`/`createFromGallery` lançam (serviço não resolve ou tabela ausente); a aba “Img” e o botão “Selecionar da Galeria” ficam inúteis; assets de imagem existentes continuam listáveis porque são linhas locais |
| `SVC-MEDIA` (`ImageRedigestService::buildPictureSnippet`, componente `v2-service-media::components.picture`) | geração do snippet de imagem e re-digestão de `snippet`/`content` | `generateImageSnippet` lança `Class not found` no upload/galeria; `v2:redigest-images` não roda; snippets já gravados permanecem |
| `SVC-STORAGE` (`StorageService`) | gravação/remoção de CSS/JS | `uploadVendor`/`deleteAsset` falham; imagens continuam via Gallery (que também usa Storage internamente) |
| `SVC-ACTIVITYLOGGER` | auditoria de upload/delete/save/SEO | em `assetsUpload`/`assetsDelete` a falha cai no `catch` do controller e vira mensagem de erro da operação; em `store`/`saveContent` não há `try/catch` → a requisição falha após o `save()`; em `updateSeo` é engolida com `Log::warning` |
| `filesystems.media_disk` | disco das imagens | Gallery grava no disco default; `storage_path` pode não ser servível |

## 3. Particularidades

### 3.1 O que só existe aqui

- **Página institucional como registro editável**: as cinco páginas fixas existem como linhas de banco criadas pela própria migration, com `content=''` e `metadata.seo.title` igual ao título (`Migrations/2026_06_29_092300_create_basic_pages_table.php:37-55`). A view Blade continua sendo o fallback — o banco é overlay, não fonte.
- **Dois níveis de restrição diferentes no mesmo módulo**: escrita de conteúdo e assets exige **e-mail de suporte** (`authorizeSupportAccess`, `BasicPageCmsController.php:33-39`; `BasicPageRuntimeController.php:24-30`), enquanto SEO e leitura usam **permissão de papel** (`v2_can('BasicPage','write')`). A listagem não tem nenhuma das duas.
- **File Library global sem vínculo com página**: não existe `page_id`; o mesmo CSS/JS/imagem serve qualquer página e não há como saber quem usa o quê.
- **Snippet persistido**: o texto a colar é calculado no upload e congelado (`snippet`); mudar o formato exige rodar `RedigestImages`.
- **Catch-all fora do módulo**: a rota vive em `V2/Core/Routes/fallback.php` e é registrada pelo Core; o módulo só fornece o controller (`Routes/web.php:16-21`).
- **Defaults de SEO/JSON-LD por slug no código** (11 slugs, incluindo slugs que pertencem a outros módulos): `homepage` (WebSite + SearchAction), `lgpd`, `acessibilidade`, `mapa-do-site`, `ouvidoria` (ContactPage), `associados`, `parceiros-institucionais`, `noticias` (NewsArticle), `convenios`, `fale-conosco`, `agenda` (`Models/BasicPage.php:32-134`).
- **Duas formas de criar UUID**: `v2_uuid()` no controller/migration e `Ramsey\Uuid::uuid4()` no model de asset (`Models/BasicPageAsset.php:32-36`).

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-BASICPAGE-01` | Só há substituição de view quando `content` é não vazio (`!empty`) | `content = '0'` ou `''` cai no Blade fixo; `' '` (espaço) **substitui** | `BasicPagePortalController.php:58` |
| `RN-BASICPAGE-02` | O catch-all só resolve linhas com `is_runtime = true` | Linha fixa com `content` não é acessível por outro slug; linha runtime responde em `/{slug}` | `BasicPagePortalController.php:149` |
| `RN-BASICPAGE-03` | Slug de rota pública é `[a-z0-9-]+` | `_`, `.`, maiúsculas e acentos dão 404 antes do controller | `V2/Core/Routes/fallback.php:19-21` |
| `RN-BASICPAGE-04` | `slug` de criação é `alpha_dash` e único | Rejeita `_` apesar de a rota também rejeitar; colisão retorna erro de validação | `BasicPageCmsController.php:192` |
| `RN-BASICPAGE-05` | `metadata['layout']` é substituído inteiro no save | Flags não enviadas voltam ao default do slug/app | `BasicPageCmsController.php:148-156` |
| `RN-BASICPAGE-06` | `metadata['seo']` é substituído inteiro no updateSeo | Campos ausentes viram `null` e caem no default do slug na leitura | `BasicPageCmsController.php:303-313` |
| `RN-BASICPAGE-07` | SEO sempre é aplicado, mesmo com fallback Blade | Metatags do banco vencem o `pageTitle` hardcoded da view | `BasicPagePortalController.php:88-95`; `V2/Services/SEO/Providers/SEOServiceProvider.php:26-64` |
| `RN-BASICPAGE-08` | `content_type` não afeta a saída | Serve só para o `mode` do CodeMirror | `Views/cms/editor.blade.php:76-81`; `V2/layouts/cms/partials/code-editor.blade.php:12-19` |
| `RN-BASICPAGE-09` | Imagem de asset referencia a galeria, não copia arquivo | Apagar a mídia na galeria deixa `storage_path` quebrado no asset | `BasicPageAssetManagerService.php:104-124` |
| `RN-BASICPAGE-10` | Dedupe de asset por `gallery_media_id` não é atômico | Corrida cria duplicatas (sem unique) | `BasicPageAssetManagerService.php:109-112`; migration sem unique |
| `RN-BASICPAGE-11` | CSS/JS não passam por Gallery nem por validação de MIME | Extensão livre, conteúdo arbitrário em disco público | `BasicPageRuntimeController.php:47-60`; `BasicPageAssetManagerService.php:28-38` |
| `RN-BASICPAGE-12` | Toda página é indexada no `saved`, ignorando `is_runtime` | Páginas “sombra” de módulos (`noticias`, `associados`…) entram no índice público como `basicpage` | `Models/BasicPage.php:136-144,240-253` |
| `RN-BASICPAGE-13` | Boot reindexa apenas páginas **sem** linha no índice, e só em console | Seed por `DB::table` não dispara hook; o catch-up cobre no próximo `artisan` | `V2/Core/Providers/V2ServiceProvider.php:145-166` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **`content` ausente no POST**: vira `''` (não `null`) por `input('content','')`, então a página passa a usar o Blade fixo (`:145`).
- **`metadata` duplamente codificado** (só em `store()`): `json_encode` explícito + cast `array` → `json_encode` de novo. MariaDB/Postgres guardam `"{\"seo\":...}"`. Na leitura, `metadata` volta como **string**, e:
  - `getSeoData()` → `isset($this->metadata['seo'])` é `false` (offset de string com chave não numérica), então cai nos defaults/título;
  - `getLayoutConfig()` → idem, cai nos defaults;
  - `saveContent()`/`updateSeo()` → `$metadata = $page->metadata ?: []` é a string, e `$metadata['seo'] = [...]` tenta escrever offset string em string (erro/warning em PHP 7.4, `TypeError` em PHP 8).
  Consequência: página criada pelo CMS fica com SEO “de fábrica” e o **primeiro save de layout ou SEO pode falhar**. `[C]` para o código; `[I]` para o comportamento em runtime (deriva de `HasAttributes::castAttributeAsJson`, vendor 5.5). Rotas que gravam metadata de forma correta: `saveContent` (array), `updateSeo` (array) e as migrations/seeders (`DB::table`, sem cast).
- **`index()` sem guarda**: qualquer usuário CMS autenticado com `BasicPage: read` lista páginas, slugs e URLs publicadas; o botão “Editar” só aparece para suporte na view (`Views/cms/index.blade.php:3,64-68`), mas a rota `GET /editor/{slug}` responde `403` para os demais.
- **Preview é iframe**: `renderPreview` busca a página e o histórico, mas a tela só embute `$url` pública (`Views/cms/preview.blade.php:49-55`). Se a página não estiver publicada/roteada, o iframe mostra o portal (ou 404), não o conteúdo em edição.
- **Upload não-AJAX redireciona para slug vazio**: os formulários da sidebar não enviam `_slug` (`asset-sidebar.blade.php:53-62,77-86`), e o controller redireciona para `route('v2.basicpage.editor.edit', ['slug' => ''])` (`BasicPageRuntimeController.php:93`) → `/cms/paginas-fixas/editor` não existe (404) `[C]`; `[I]` o usuário perde a tela após o upload (o asset é gravado).
- **Exclusão de asset inalcançável pela UI**: a única tela que mostra o formulário de delete é o fragmento de `assets.index`; a sidebar remove `.btn-outline-danger` do card clonado (`asset-sidebar.blade.php:193-195`). Não há página completa em `/editor/assets` `[C]`.
- **Cópia do snippet sai escapada**: `$_snip = htmlspecialchars(...)` e depois `data-snippet="{{ $_snip }}"` escapa de novo (`asset-card.blade.php:50-55`) `[C]`; `[I]` o `dataset.snippet` lido pelo JS contém `&lt;link ...` e o texto copiado fica inutilizável.
- **Aviso de “alterações não salvas” nunca arma**: o CodeMirror dispara `sle:editor-change` no **elemento** com `bubbles:false` (`V2/layouts/cms/partials/code-editor.blade.php:84-87`), e o editor escuta em `document` (`Views/cms/editor.blade.php:163-173`) `[C]`; `[I]` evento não sobe → `hasUnsavedChanges` fica sempre `false`.
- **Página “sombra”**: seeders criam linhas `is_runtime=true` para `noticias`, `associados`, `convenios`, `agenda`, `fale-conosco`, `parceiros-institucionais` (`V2/Seeds/BasicPages/NoticiasSeeder.php:70-106`). Elas aparecem no CMS e no índice de busca, mas o portal serve a rota do módulo específico — o `content` nunca é renderizado. Salvar conteúdo nelas no editor não muda a página pública.
- **`v2.portal.runtime` com slug manipulado**: `route('v2.portal.runtime', ['slug' => $slug])` gera URL para qualquer slug, mas a rota só casa `[a-z0-9-]+`; slugs com `_` (vindos de `store`? impossível, `alpha_dash` permite `_`) gerariam link quebrado. `alpha_dash` aceita `_`; a rota não.
- **Cache**: depois de salvar, a página pública continua até 5 min com o HTML anterior (Redis), porque o módulo não invalida `page:*`. O seeder da homepage faz limpeza manual via `Redis::keys` (`V2/Seeds/BasicPages/SucesuHomepageSeeder.php:412-421`), o CMS não.

### 3.4 Armadilhas de reimplementação

1. **Tratar BasicPage como LSE/SLE**. LSE (`V2/Services/LandingSectionsEditor`) edita **seções tipadas** (21 tipos padrão no registry; 36 no boot normal com os 15 widgets de módulo; `v2_layout_base_configs.payload`) e SLE (`V2/Modules/Support/StructuralLandingEditor`) renderiza **blocos** com `BlockRenderer::compileString` (`BlockRenderer.php:144-145`) sobre `v2_structural_portal_contents`. BasicPage não tem seção, nem bloco, nem compilação: um único `content` e um `{!! !!}`.
2. **Assumir injeção automática de assets**. Não existe `@push` de CSS/JS por página, nem consulta a `v2_basic_page_assets` no render. O snippet é o único caminho — e, para CSS/JS, é uma string com `{{ asset('storage/...') }}` (`BasicPageAssetManagerService.php:169-179`) que **não é compilada** no conteúdo runtime.
3. **Contar com compilação Blade no conteúdo**. `ImageRedigestService::redigestContentBlobs` reescreve `@include('v2-service-media::components.picture',...)` em `v2_basic_pages.content` (`ImageRedigestService.php:217-228`), o que só faz sentido se o conteúdo for compilado. Neste módulo ele não é. Reimplementar escolhendo um dos dois contratos — compilar (e então sanitizar) ou não compilar (e então gerar HTML puro) — é decisão obrigatória; manter os dois é inconsistência.
4. **Omitir a ordem das rotas**. `/editor/assets*` antes de `/editor/{slug}`; e o catch-all depois de **todos** os módulos (`V2ServiceProvider.php:134-137,317-335`). Inverter muda o roteamento de outros módulos.
5. **Esquecer que o layout vem do banco**. Uma página runtime sem `metadata.layout` herda `showHeader/showMainHeader/showMainFooter/showFooter = true` e `fullWidthMain = false`; a homepage tem o conjunto oposto. Renderizar com defaults fixos no Blade quebra a home do CMS.
6. **Ler `metadata` como array sem checar tipo**. Páginas criadas por `store()` devolvem string (ver §3.3); código que faz `$page->metadata['x']` pode estourar.

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

- **`metadata` duplo-encode em `store()`** — `[C]` `BasicPageCmsController.php:224` vs `Models/BasicPage.php:22-24`. Bug funcional; qualquer reimplementação deve gravar array (o cast já serializa) ou remover o cast.
- **Snippet Blade vs conteúdo não compilado** — `[C]` `BasicPageAssetManagerService.php:169-186` gera `{{ asset(...) }}` / `@include(...)`; `[C]` `Views/portal/runtime.blade.php:4` ecoa cru; `[C]` nenhum `compileString` para `v2_basic_pages.content` fora de `ImageRedigestService` (que só valida). `[I]` na prática o snippet aparece como texto na página.
- **Comentário “Runtime Editor (founder only)”** em `Routes/cms.php:18` — a restrição não é da rota e sim do controller; `index` e `preview` ficam abertos a qualquer perfil com leitura.
- **`getSeoData()` diz “prioriza valores do banco”**, mas o `json_ld` default tem `url` recalculada sempre que não há `json_ld` salvo — um `json_ld` salvo com `url` errada nunca é corrigido (`BasicPage.php:204-215`).
- **`$defaultView` e `$hardcodedParams` de `renderWithRuntimeFallback` são ignorados** no ramo runtime e nunca usados no ramo fallback (`BasicPagePortalController.php:56-68`); o parâmetro existe mas a decisão real é do chamador.
- **`homepage()` do controller de portal é código morto**: a rota `/` foi removida e pertence a `V2/Modules/Layouts/Homepage/Routes/web.php:8`; nenhuma rota aponta para `BasicPagePortalController@homepage`. O SEO default de `homepage` ainda é usado pelo sitemap/`getSeoData` chamado pela listagem do CMS, mas não pelo portal.
- **`V2/Modules/Layouts/BasicPage/Views/portal/homepage.blade.php`** (318 linhas) é um tema estático legado: a homepage real é renderizada por LSE (`HomepagePortalController.php:45-68`); o seeder atual zera `content` da homepage e remove o runtime (`SucesuHomepageSeeder.php:396-410`).
- **Assets sem índice e sem página**: `type`/`created_at`/`gallery_media_id` sem índice; a listagem cresce sem paginação.
- **`preview.blade.php` tem HTML desbalanceado** (`</header>` e `</div>` extras, `:24-45`) — cosmético no CMS.
- **`Views/cms/index.blade.php:35`** fecha um `</header>` que nunca foi aberto.
- **Sem i18n**: não existe `resources/lang`/`lang` no repositório, embora `config/app.php:84` declare `pt-br`; as mensagens de validação resolvem para as chaves cruas (`validation.*`) `[I]` — ver `LG-BASICPAGE-03`.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `BasicPage` | Model | `$table='v2_basic_pages'`, `$fillable=[id,slug,title,content,content_type,is_runtime,metadata]`, cast `metadata=array` | tabela migrada | controllers do módulo; `V2ServiceProvider`; `ReindexPublicSearch`; `SeoPortalController` |
| `BasicPage::getLayoutConfig()` | método | `(): array{9 bool}` | — | `BasicPagePortalController`; `Views/cms/editor` |
| `BasicPage::getSeoData()` | método | `(): array{title,description,keywords,image_url,image_id,json_ld}` | — | `BasicPagePortalController::bindBasicPage`; `BasicPageCmsController::index`; `Views/cms/index,preview` |
| `BasicPage::syncToSearch()` | método | `(): void` | tabelas de índice existentes (falha é engolida pelos indexadores) | hook `saved`; boot catch-up; `ReindexPublicSearch::reindexBasicPages` |
| `BasicPageAsset` | Model | `$table='v2_basic_page_assets'`, `SoftDeletes`, UUID em `creating` | tabela migrada | `BasicPageRuntimeController`; `BasicPageAssetManagerService` |
| `BasicPageAssetManagerService` | serviço (singleton) | ver §4.4 | `StorageService` + `GalleryService` resolvíveis | controllers do módulo; `V2/Seeds/BasicPages/ProcessesGalleryImages`; `RedigestImages` (indireto) |
| `BasicPagePortalController` | controller | actions `lgpd,acessibilidade,mapaSite,ouvidoria,runtime($slug)` | — | `Routes/web.php`; `V2/Core/Routes/fallback.php` |
| `ModuleProvider::getConfig()` | config | título “Páginas do Portal”, `active_pattern='cms/paginas-fixas*'`, `route='v2.basicpage.index'`, `position=1`, 2 submenus | — | `SidebarComposer`; `V2PermissionMiddleware` |

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

| Componente | Namespace / rota | Props / estado | DOM / eventos | Observações |
|---|---|---|---|---|
| `cms/editor.blade.php` | `v2-basicpage::cms.editor` | `$page`; CodeMirror id `bp-editor-{slug}`, name `content`, mode `$page->content_type` (500 px); 9 switches `layout[*]` | form POST `v2.basicpage.editor.save`; listener `sle:editor-change` (não funciona); `beforeunload` | `@include('v2-cms::partials.activity-footer', ['model'=>$page])` |
| `cms/create.blade.php` | `v2-basicpage::cms.create` | form `title`, `slug`, `content`, `layout[*]`; CodeMirror `bp-editor-create` | JS auto-slug a partir do título até o usuário editar o slug (`:161-186`) | slug `pattern="[a-z0-9-]+"` no cliente |
| `cms/index.blade.php` | `v2-basicpage::cms.index` | `$pages[] = {slug,title,model,portal_url,seoData}`, `$history=null` | modais `#modalSeo_{slug}` com `v2-service-seo::components.seo-input`, POST `v2.basicpage.seo.update` | botão “Editar” só se suporte; “SEO” só com `@v2can('BasicPage','write')` |
| `cms/preview.blade.php` | `v2-basicpage::cms.preview` | `$title,$url,$page,$history` | iframe `src=$url`; modal SEO duplicado | JS copia `seo_image_id/image_url` para `seo[image_id]/seo[image_url]` no submit |
| `cms/assets/index.blade.php` | fragmento | `$assets`, `$type` | cards `.bp-asset-card[data-asset-id][data-asset-type]` | HTML sem layout, consumido por `fetch` |
| `cms/components/asset-sidebar.blade.php` | include | inclui `v2-service-gallery::components.gallery-picker` com `name=picker_bp_sidebar`, `ownerModule=basicpage`, `allowedMimeTypes=['image/*']`, `allowShared=true` | abas `#bp-sbar-css\|js\|img`; `lotep:open-gallery-picker-picker_bp_sidebar`; `fetch` de listagem e de `assets/from-gallery` | remove delete/copiar dos cards; copia via `navigator.clipboard` ou `execCommand` |
| `cms/components/asset-card.blade.php` | include | `$asset`, `$showSnippet` | `.bp-snippet-bar[data-snippet]`, `.copy-snippet-btn[data-snippet]` | tamanho formatado em KB/MB (`:14-16`) |
| `portal/runtime.blade.php` | `v2-basicpage::portal.runtime` | `$page`, `$layoutParams` | `@extends('v2-portal::app-layout', $layoutParams)`; `@section('main-section-area') {!! content !!}`; `@push('head-styles')` e `@push('scripts')` com Swiper de `.brand-slider` | Swiper opcional: se `Swiper` não existir, não inicializa |
| `portal/{homepage,lgpd,acessibilidade,mapa_site,ouvidoria}` | `v2-basicpage::portal.*` | `@extends('v2-portal::app-layout', [...])` com `pageTitle`, `pageDescription`, flags de layout hardcoded | sem JS próprio exceto estilos inline | `homepage` não é roteada; ver §3.5 |

### 4.3 Ordem e ciclo de vida

1. `V2ServiceProvider::boot()` descobre `V2/Modules/Layouts/BasicPage`, instancia `ModuleProvider` (registra o singleton do asset manager), carrega migrations, views `v2-basicpage`, rotas web e CMS.
2. Rotas do Core → módulos (ordem alfabética de grupo/módulo) → `mapFallbackRoutes()` → serviços (`V2ServiceProvider.php:85-218`).
3. `SEOServiceProvider::boot()` registra o composer de `v2-portal::metadata*`; o singleton `SEOService` é o mesmo injetado no `BasicPagePortalController` (`SEOServiceProvider.php:26-64,66-69`).
4. `StructuralLandingEditor\ModuleProvider` registra `LayoutBaseComposer` para `v2-basicpage::*`, injetando `$layoutConfigs` (tabela `v2_layout_base_configs`) em **todas** as views do módulo (`V2/Modules/Support/StructuralLandingEditor/ModuleProvider.php:24-25`).
5. `saved` do model → indexadores (síncrono).
6. `runningInConsole()` → catch-up de indexação (uma vez por processo artisan).

### 4.4 Receitas de uso

```php
// Ler a configuração efetiva de layout e repassar ao layout do portal
$page = \V2\Modules\Layouts\BasicPage\Models\BasicPage::where('slug', 'lgpd')->firstOrFail();
return view('v2-basicpage::portal.runtime', [
    'page'         => $page,
    'layoutParams' => $page->getLayoutConfig(),   // 9 chaves bool
]);
```

Resultado: a view estende `v2-portal::app-layout` com `fullWidthMain`, `showHeader`, `showMainHeader`, `showMainFooter`, `showFooter`, `showAccessibilityBar`, `stickyAccessibilityBar`, `showMainLeftArea`, `showMainRightArea`.

```php
// Aplicar o SEO da página no serviço global (mesma sequência de bindBasicPage)
$seo  = app(\V2\Services\SEO\Services\SEOService::class);
$data = $page->getSeoData();

$seo->setTitle($data['title']);
$seo->setDescription($data['description']);
$seo->setKeywords($data['keywords']);
if ($data['image_url']) { $seo->setCoverImage($data['image_url']); }
if ($data['json_ld'])   { $seo->addSchema($data['json_ld']); }
```

Resultado: `<title>`, `meta description/keywords`, `og:*`, `twitter:*`, `canonical` e `<script type="application/ld+json">` no `<head>` (render via `v2-portal::metadata.seo`). Precondição: rodar **antes** da view; o composer lê o singleton no render.

```php
// Registrar uma imagem já existente na galeria como asset da File Library
$asset = app(\V2\Modules\Layouts\BasicPage\Services\BasicPageAssetManagerService::class)
    ->createFromGallery($galleryMediaId, 'Descrição da imagem');

echo $asset->snippet; // @include('v2-service-media::components.picture', [...])
```

Resultado: uma linha em `v2_basic_page_assets` (`type=image`, `gallery_media_id` preenchido) ou a linha já existente, sem novo upload. Lança `Exception('Midia nao encontrada na galeria (ID: N)')` se o id não existir.

```php
// Criar uma página runtime por código, respeitando o cast de metadata
$page = \V2\Modules\Layouts\BasicPage\Models\BasicPage::create([
    'id'         => v2_uuid(),
    'slug'       => 'transparencia',
    'title'      => 'Transparência',
    'content'    => '<h1>Transparência</h1>',
    'is_runtime' => true,
    'metadata'   => ['seo' => ['title' => 'Transparência — Portal'], 'layout' => ['showHeader' => true]],
]);
// $page->save() já indexou em v2_public_search_index e v2_cms_links_index
```

Resultado: `GET /transparencia` passa a responder `200` (sujeito a cache de 5 min) e a página aparece em `/cms/paginas-fixas`.

```bash
# Reindexar as páginas após seed por DB::table (que não dispara o hook)
php artisan v2:reindex-public --only=basicpages   # helper do SVC-SEARCH; ou qualquer artisan dispara o catch-up do boot
```

Resultado: linhas faltantes em `v2_public_search_index`/`v2_cms_links_index` são criadas. Sem isso, o catch-up só roda no próximo comando artisan.

### 4.5 Anti-uso

```php
// ERRADO: passar metadata já serializado para um model com cast array
BasicPage::create([... 'metadata' => json_encode(['seo' => [...]])]);
// Efeito: dupla codificação; getSeoData()/getLayoutConfig() ignoram o valor e o próximo save pode estourar.
```

```php
// ERRADO: esperar que Blade seja interpretado no conteúdo runtime
$page->content = "@include('v2-service-media::components.picture', ['originalPath' => 'x'])";
// Efeito: o texto aparece literal na página; use HTML puro (<picture>/<img>) ou compile explicitamente.
```

```php
// ERRADO: assumir que salvar invalida o cache público
$page->content = $novoHtml; $page->save();
// Efeito: GET /{slug} continua servindo a versão anterior por até 5 min (Redis page:<sha1(fullUrl)>).
// Invalide page:* ou use o comando de limpeza do CORE.
```

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

- Upload limitado a **10240 KB** por arquivo (`BasicPageRuntimeController.php:48`); sem limite de quantidade, sem paginação na listagem, sem quota por página.
- Cada abertura do editor dispara 3 requisições de listagem de assets; cada upload dispara mais 3 (após 500 ms).
- `content` é `text` (sem limite prático no Postgres, 64 KB no MySQL `TEXT`); o CodeMirror carrega o valor inteiro no HTML e no POST.
- Indexação síncrona: salvar uma página grande custa 2 upserts com `strip_tags` do conteúdo inteiro; se o índice falhar, o `save()` **não** falha (erros engolidos em `try/catch` + `Log::error`).
- Cache de página: 5 min por URL completa; o cache não é invalidado por escrita; `cms*` está fora do cache (`PageCacheMiddleware.php:16-23`).
- `public.cache` adiciona `Cache-Control: public, max-age=60, must-revalidate` + `Vary: Accept-Encoding` nas respostas 2xx do portal (`PublicCacheMiddleware.php:47-114`).
- 404 do `firstOrFail` também é cacheável? Não: `isSuccessful()` barra o cache (`PageCacheMiddleware.php:44`).

## 5. Rotas e contratos HTTP

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|---|
| grupo | — | `cms/paginas-fixas` | — | `web`, `module.active:BasicPage`, `CmsAuthenticate`→`V2PermissionMiddleware` | — | `BasicPage` (read/write) | aplica flag de módulo e RBAC | `Routes/cms.php:9`; `V2ServiceProvider.php:302-306` |
| endpoint | GET | `/cms/paginas-fixas` | `v2.basicpage.index` | idem | `BasicPageCmsController@index` | `BasicPage:read` | lista páginas + URLs públicas | `Routes/cms.php:10` |
| endpoint | GET | `/cms/paginas-fixas/preview/homepage` | `v2.basicpage.preview.homepage` | idem | `@previewHomepage` | `BasicPage:read` | iframe de `v2.portal.home` + histórico | `Routes/cms.php:11` |
| endpoint | GET | `/cms/paginas-fixas/preview/lgpd` | `v2.basicpage.preview.lgpd` | idem | `@previewLgpd` | `BasicPage:read` | idem para `/lgpd` | `Routes/cms.php:12` |
| endpoint | GET | `/cms/paginas-fixas/preview/acessibilidade` | `v2.basicpage.preview.acessibilidade` | idem | `@previewAcessibilidade` | `BasicPage:read` | idem | `Routes/cms.php:13` |
| endpoint | GET | `/cms/paginas-fixas/preview/mapa-do-site` | `v2.basicpage.preview.mapa_site` | idem | `@previewMapaSite` | `BasicPage:read` | idem | `Routes/cms.php:14` |
| endpoint | GET | `/cms/paginas-fixas/preview/ouvidoria` | `v2.basicpage.preview.ouvidoria` | idem | `@previewOuvidoria` | `BasicPage:read` | idem | `Routes/cms.php:15` |
| endpoint | POST | `/cms/paginas-fixas/seo/update` | `v2.basicpage.seo.update` | idem | `@updateSeo` | `BasicPage:write` (checado **duas vezes**: middleware + `v2_can`) | grava `metadata.seo`, audita, reindexa | `Routes/cms.php:16`; `:287-289` |
| endpoint | GET | `/cms/paginas-fixas/editor/create` | `v2.basicpage.editor.create` | idem | `@create` | `BasicPage:read` + e-mail de suporte | formulário de nova página | `Routes/cms.php:19` |
| endpoint | POST | `/cms/paginas-fixas/editor/store` | `v2.basicpage.editor.store` | idem | `@store` | `BasicPage:write` + e-mail de suporte | cria página runtime | `Routes/cms.php:20` |
| endpoint | GET | `/cms/paginas-fixas/editor/assets` | `v2.basicpage.assets` | idem | `BasicPageRuntimeController@assets` | `BasicPage:read` + e-mail de suporte | fragmento HTML de cards | `Routes/cms.php:23` |
| endpoint | POST | `/cms/paginas-fixas/editor/assets/upload` | `v2.basicpage.assets.upload` | idem | `@assetsUpload` | `BasicPage:write` + e-mail de suporte | grava arquivo + linha; JSON/flash | `Routes/cms.php:24` |
| endpoint | POST | `/cms/paginas-fixas/editor/assets/from-gallery` | `v2.basicpage.assets.from-gallery` | idem | `@assetsFromGallery` | `BasicPage:write` + e-mail de suporte | cria/reaproveita asset de imagem; JSON | `Routes/cms.php:25` |
| endpoint | POST | `/cms/paginas-fixas/editor/assets/delete/{id}` | `v2.basicpage.assets.delete` | idem | `@assetsDelete` | `BasicPage:write` + e-mail de suporte | soft delete + remoção física | `Routes/cms.php:26` |
| endpoint | GET | `/cms/paginas-fixas/editor/{slug}` | `v2.basicpage.editor.edit` | idem | `BasicPageCmsController@edit` | `BasicPage:read` + e-mail de suporte | CodeMirror + switches | `Routes/cms.php:28` |
| endpoint | POST | `/cms/paginas-fixas/editor/save` | `v2.basicpage.editor.save` | idem | `@saveContent` | `BasicPage:write` + e-mail de suporte | grava conteúdo/layout, audita, reindexa | `Routes/cms.php:29` |
| grupo | — | `/` (web do módulo) | — | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | — | pública | cache de 5 min + headers públicos | `V2ServiceProvider.php:297-301` |
| endpoint | GET | `/lgpd` | `v2.portal.lgpd` | idem | `BasicPagePortalController@lgpd` | pública | view fixa ou runtime | `Routes/web.php:11` |
| endpoint | GET | `/acessibilidade` | `v2.portal.acessibilidade` | idem | `@acessibilidade` | pública | idem | `Routes/web.php:12` |
| endpoint | GET | `/mapa-do-site` | `v2.portal.mapa_site` | idem | `@mapaSite` | pública | idem | `Routes/web.php:13` |
| endpoint | GET | `/ouvidoria` | `v2.portal.ouvidoria` | idem | `@ouvidoria` | pública | idem | `Routes/web.php:14` |
| endpoint (Core) | GET | `/{slug}` `[a-z0-9-]+` | `v2.portal.runtime` | idem (registrada por último) | `BasicPagePortalController@runtime` | pública | view runtime ou 404 | `V2/Core/Routes/fallback.php:19-21` |

**Payloads CMS**

- `editor/store` e `editor/save`: `slug` (string, obrigatório), `title` (só no store), `content` (string, opcional), `content_type` ∈ `htmlmixed|css|javascript`, `layout.<flag>` ∈ `{0,1,true,false}` para `fullWidthMain`, `showHeader`, `showMainHeader`, `showMainFooter`, `showFooter`, `showAccessibilityBar`, `stickyAccessibilityBar`, `showMainLeftArea`, `showMainRightArea`.
- `seo/update`: `slug`, `seo[title]` (obrigatório, ≤255), `seo[description]` (≤500), `seo[keywords]` (≤255), `seo[json_ld]`, `seo[image_id]`, `seo[image_url]`.
- `assets/upload`: multipart `file` (≤10240 KB), `type`, `alt_text` (opcional, só imagem), `_slug` (opcional, usado no redirect).
- `assets/from-gallery`: `gallery_media_id` (inteiro ≥1), `alt_text`.
- CSRF obrigatório em todos os `POST`; nenhum throttle específico (`web` default).

## 6. Dados e modelo de domínio

### 6.1 `v2_basic_pages`

| Item | Valor |
|---|---|
| PK | `id` uuid (sem default no banco; preenchido por `v2_uuid()` na migration e no `store`) |
| Colunas | `id` uuid PK; `slug` string **unique**; `title` string; `content` text nullable; `content_type` string(50) default `htmlmixed`; `is_runtime` boolean default `false`; `metadata` jsonb nullable; `created_at`/`updated_at` timestamp |
| FK | nenhuma |
| Índices | PK + unique(`slug`) |
| Soft delete | não |
| Quem escreve | `BasicPageCmsController@saveContent/store/updateSeo`; migration (5 linhas); seeders `V2/Seeds/BasicPages/*` via `DB::table`; `ImageRedigestService` (só `content`, em `--entity=content`) |
| Model | `BasicPage`, `$incrementing=false`, `$keyType='string'`, cast `metadata=array`, hook `saved` |
| Fonte | `Migrations/2026_06_29_092300_create_basic_pages_table.php:23-34`; `Models/BasicPage.php:13-24` |

Estrutura de `metadata` (contrato de fato, sem validação de schema):

```json
{
  "seo": {"title": "...", "description": "...", "keywords": "...", "json_ld": { }, "image_id": "...", "image_url": "..."},
  "layout": {"fullWidthMain": true, "showHeader": false, "showMainHeader": false,
             "showMainFooter": false, "showFooter": false, "showAccessibilityBar": false,
             "stickyAccessibilityBar": false, "showMainLeftArea": false, "showMainRightArea": false}
}
```

### 6.2 `v2_basic_page_assets`

| Item | Valor |
|---|---|
| PK | `id` uuid (sem default; `BasicPageAsset::creating` gera com Ramsey) |
| Colunas | `id` uuid PK; `type` enum(`css`,`js`,`image`); `original_name` string; `storage_path` string; `mime_type` string(127) nullable; `file_size` bigint nullable; `gallery_media_id` integer unsigned nullable; `snippet` text nullable; `created_at`/`updated_at`; `deleted_at` |
| FK | nenhuma (nem para `v2_gallery_media`, nem para `v2_basic_pages`) |
| Índices | PK apenas |
| Soft delete | sim (`deleted_at`) |
| Quem escreve | `BasicPageAssetManagerService::{uploadVendor,uploadImage,createFromGallery}`; `deleteAsset` (soft delete); `ImageRedigestService` (só `snippet`) |
| Model | `BasicPageAsset`, `$incrementing=false`, `$dateFormat='Y-m-d H:i:s'`, cast `file_size=integer` |
| Fonte | `Migrations/2026_07_06_000200_create_basic_page_assets_table.php:18-29`; `Models/BasicPageAsset.php:11-37` |

```mermaid
erDiagram
  V2_BASIC_PAGES {
    uuid id PK
    string slug UK
    string title
    text content
    string content_type
    boolean is_runtime
    jsonb metadata
  }
  V2_BASIC_PAGE_ASSETS {
    uuid id PK
    enum type
    string original_name
    string storage_path
    string mime_type
    bigint file_size
    int gallery_media_id
    text snippet
    timestamp deleted_at
  }
  V2_GALLERY_MEDIA {
    bigint id PK
    string file_path
    json variants
    string alt_text
  }
  V2_PUBLIC_SEARCH_INDEX {
    uuid id PK
    string module
    string entity_type
    uuid entity_id UK
    string slug
    text search_deep_content
  }
  V2_BASIC_PAGE_ASSETS }o..o| V2_GALLERY_MEDIA : "gallery_media_id (lógico, sem FK)"
  V2_BASIC_PAGES ||..o{ V2_PUBLIC_SEARCH_INDEX : "entity_id = id (lógico)"
```

### 6.3 Models — detalhes

- `BasicPage`: `$fillable` cobre todos os campos exceto timestamps; `$casts['metadata']='array'`; **sem** `creating` (id obrigatório no `create`); `boot()` registra `saved`; `$seoDefaults` (11 slugs) e `$layoutDefaults` (só `homepage`) são propriedades estáticas protegidas, não config.
- `BasicPageAsset`: usa `Ramsey\Uuid\Uuid::uuid4()` no `creating`; `$dateFormat` explícito (o Postgres da aplicação grava `Y-m-d H:i:s`); sem relações Eloquent (não há `gallery()`/`page()`).

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Fixa: migration (content='', is_runtime=false)
  [*] --> Runtime: store (is_runtime=true, content='')
  Fixa --> Overlay: saveContent com content != ''
  Overlay --> Fixa: saveContent com content = ''
  Runtime --> Overlay: saveContent com content != ''
  Overlay --> Overlay: saveContent / updateSeo (saved → index)
  Runtime --> Runtime: saveContent / updateSeo (saved → index)
```

Não há estado “publicado/rascunho”: a existência de `content` é o único interruptor. `is_runtime` só controla o acesso pelo catch-all, não a renderização das rotas fixas.

```mermaid
sequenceDiagram
  actor S as Suporte
  participant C as BasicPageCmsController
  participant M as BasicPage (Eloquent)
  participant L as ActivityLoggerService
  participant P as PublicIndexerService
  participant X as CMSIndexerService
  participant R as Redis (page cache)

  S->>C: POST /cms/paginas-fixas/editor/save (slug, content, layout[*])
  C->>C: authorizeSupportAccess() + validate()
  C->>M: firstOrFail + atribui content/content_type/metadata
  C->>M: save()
  M-->>P: saved → index(PublicSearchDTO)
  M-->>X: saved → index(CMSIndexDTO)
  C->>L: log('updated', {old,new})
  C-->>S: 302 editor + flash success
  Note over R: nenhuma chave page:* é removida<br/>portal segue em cache até 5 min
```

## 8. UI CMS

### 8.1 Listagem — `GET /cms/paginas-fixas`

- Layout `v2-cms::layouts.app`; breadcrumb “Layouts › Páginas Básicas”; título “Páginas do Portal”; ação “Criar Página” só para suporte (`Views/cms/index.blade.php:2-24`).
- Tabela `Título | Slug | Ações`, sem filtros, busca, ordenação, paginação ou bulk actions. Badge `Fallback`/`Runtime` conforme `empty($model->content)` (`:53-58`).
- Ações por linha: “Editar” (suporte), “SEO” (`@v2can('BasicPage','write')`), “Ver” (`target="_blank"` para `portal_url`; se `null`, o `href` fica vazio).
- Modais de SEO: um por linha, `#modalSeo_{slug}`, com `v2-service-seo::components.seo-input` (`model` clonado com `metadata['seo'] = getSeoData()`); rodapé “Cancelar”/“Salvar SEO”.
- Estado vazio: tabela sem `<tbody>`; nenhuma mensagem.
- A11y: `aria-label="Páginas"` na tabela, `aria-labelledby/aria-modal` nos modais, `btn-close` com `aria-label="Fechar"`.
- O HTML tem `</header>` órfão em `:34`.

### 8.2 Editor — `GET /cms/paginas-fixas/editor/{slug}`

- Cabeçalho: `Slug: <code>`, badges `Runtime|Fixa` e `Conteúdo runtime ativo|Usando fallback (Blade fixo)`; botões “Voltar” e “Ver no portal” (rota resolvida inline em PHP, com fallback `#`).
- Card “Conteúdo Runtime” com botão “Salvar”; CodeMirror 500 px, tema `material`, `lineNumbers`, `lineWrapping`, `indentUnit/tabSize=4`, `mode` = `content_type` mapeado (`htmlmixed→htmlmixed`, `css→css`, `javascript→javascript`, `php→application/x-httpd-php`) (`V2/layouts/cms/partials/code-editor.blade.php:12-19`).
- Seção “Opções de Layout” em dois blocos: **Conteúdo Principal** (`fullWidthMain`, `showMainHeader`, `showMainFooter`, `showMainLeftArea`, `showMainRightArea`) e **Elementos do Portal** (`showHeader`, `showFooter`, `showAccessibilityBar`, `stickyAccessibilityBar`), cada um com hidden `0` + switch `1` e descrição em português (`Views/cms/editor.blade.php:86-155`).
- Coluna direita: `asset-sidebar` (abas CSS/JS/Img, upload, galeria).
- Rodapé de auditoria: `v2-cms::partials.activity-footer` com `model=$page`.
- Flash: `flash::message` (sucesso `Conteúdo de "<título>" salvo com sucesso!`).
- Erros de validação: bloco `flash::message` + `$errors` do layout; mensagens padrão do framework (ver `LG-BASICPAGE-03`).

### 8.3 Criar página — `GET|POST .../editor/create|store`

- Campos: `Título` (obrigatório, ≤255), `Slug` (obrigatório, ≤100, `pattern="[a-z0-9-]+"`, prefixo visual `/`), `Conteúdo Inicial` (CodeMirror 400 px, default `htmlmixed`), mesmos 9 switches com defaults `showHeader/showMainHeader/showMainFooter/showFooter = true` (`Views/cms/create.blade.php:82-105`).
- JS: auto-slug por `input` no título (NFD, remove acentos, minúsculas, `[^a-z0-9\s-]`, espaços→`-`, colapsa `-`, trim), desativado após o usuário editar o slug (`:161-186`).
- Erro exato de slug inválido no cliente: bloqueio nativo do `pattern`; no servidor, `The slug may only contain letters, numbers, and dashes.`.

### 8.4 Preview — `GET .../preview/{pagina}`

- Iframe `75vh` apontando para a URL pública; botões “Editar Conteúdo” (suporte), “SEO” (`v2can`), “Abrir em Nova Aba”.
- Modal SEO próprio, menor (`modal-md`), com o mesmo componente; JS de submit copia `input[name="seo_image_id"]` → `seo[image_id]` e o `src` do preview → `seo[image_url]` (`Views/cms/preview.blade.php:98-113`).
- Histórico de auditoria no rodapé via `$history` (`getHistory`).

### 8.5 File Library — `GET .../editor/assets?type=`

- Fragmento sem layout; cards com ícone por tipo (`bi-filetype-css|js|raw`), nome, `Tipo · tamanho · d/m/Y H:i`, botão “Copiar” e form de exclusão com `confirm('Tem certeza que deseja remover este asset?')` (`asset-card.blade.php:1-58`).
- Barra de snippet com `data-snippet` (double-escaped) e ícone de copiar.
- Vazio: `Nenhum asset <tipo> encontrado.` (`Views/cms/assets/index.blade.php:9-11`).
- Sidebar: abas `CSS|JS|Img`; listas `[data-asset-type]` carregadas por `fetch` (3 requisições); upload por form multipart; Img abre o Gallery picker (`#bpSbarGalleryBtn`), cujo callback chama `assets/from-gallery` e recarrega a aba. Toggle de formulário troca o rótulo entre `Upload CSS` e `Cancelar`.
- A11y: `role="tablist"/"tab"/"tabpanel"` sem `aria-selected`; botões de confirmar com `aria-label="Confirmar"`; card de snippet é `role="button"` sem `tabindex`/teclado.

## 9. UI Portal

### 9.1 Páginas fixas (Blade)

| Rota | View | Layout hardcoded | Conteúdo |
|---|---|---|---|
| `/lgpd` | `portal/lgpd.blade.php` | `showHeader=true`, `showAccessibilityBar=true`, `stickyAccessibilityBar=true`, `showMainHeader/Footer=true`, `showFooter=true`, sidebars `false`; `pageTitle='LGPD - Proteção de Dados'` | artigo com “Compromisso com a sua Privacidade”, seções numeradas sobre dados coletados, bases legais etc. |
| `/acessibilidade` | `portal/acessibilidade.blade.php` | idêntico; `pageTitle='Acessibilidade Digital'` | declaração eMAG/WCAG, tabela de atalhos `Alt+1..4`, contraste |
| `/mapa-do-site` | `portal/mapa_site.blade.php` | idêntico; `pageTitle='Mapa do Site'` | árvore de links em duas colunas |
| `/ouvidoria` | `portal/ouvidoria.blade.php` | idêntico; `pageTitle='Ouvidoria Oficial'` | grid de canais com CSS `subgrid` progressivo + fallback |
| `/` | **não é deste módulo** (`Homepage`) | — | `portal/homepage.blade.php` só existe como fallback legado |
| `/{slug}` | `portal/runtime.blade.php` | `$layoutParams` do banco | `{!! $page->content !!}` + Swiper opcional para `.brand-slider` |

SEO das páginas fixas: `bindBasicPage` roda **antes** da escolha da view, então `getSeoData()` (banco > defaults do slug) alimenta `<title>`, description, keywords, OG/Twitter, canonical (`url()->current()`), cover image e JSON-LD; o `pageTitle/pageDescription` hardcoded só vale quando o campo correspondente está vazio (`V2/layouts/portal/metadata/seo.blade.php:1-47`). O `canonical` nunca é configurável pelo CMS — é sempre a URL corrente.

### 9.2 Página runtime (`/{slug}`)

- Uma query `where slug = ? and is_runtime = true`; 404 se não houver (`BasicPagePortalController.php:149`).
- `layoutParams` = `getLayoutConfig()` **sem** merge dos parâmetros fixos (o `$hardcodedParams` só é usado nas rotas fixas, com `[]`).
- O HTML é o conteúdo salvo, sem sanitização, sem markdown, sem escaping (`{!! !!}`): o autor tem poder de `<script>`.
- O CSS/JS do tema do portal vem do layout (`v2-portal::base`), não dos assets da File Library.
- Resposta passa por `page.cache` (Redis, 5 min) e ganha `Cache-Control: public, max-age=60, must-revalidate`, `Vary: Accept-Encoding`, `X-Cache-Status: HIT|MISS`.
- SEO: `metadata.seo` do banco; se ausente, `$seoDefaults[$slug]` (11 slugs); por último, `v2_basic_pages.title` — `getSeoData()` devolve `title` sempre não vazio (`$defaults['title'] ?? $this->title`) e `bindBasicPage` chama `setTitle` com ele. `$siteTitle`/`pageTitle` só entram no partial quando `$seoTitle` não é definido (ex.: página sem registro).

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

| Chave / item | Default | Efeito | Consumidores | Fonte |
|---|---|---|---|---|
| `v2.support.email` | `env('V2_SUPPORT_EMAIL','suporte@maxima.inf.br')` | e-mail que passa por `authorizeSupportAccess` e pelo bypass de RBAC | `BasicPageCmsController`, `BasicPageRuntimeController`, `ModuleProvider::getConfig()['subModulesLevel1'][1]['restricted']`, view `index` | `config/v2.php:3-6`; `BasicPageCmsController.php:33-39` |
| `v2.support.password` | `env('V2_FOUNDER_PASSWORD','')` | não usado por este módulo | — | `config/v2.php:7` |
| `filesystems.media_disk` | `public` | disco usado pela Gallery para imagens (`GalleryService::upload`) | `BasicPageAssetManagerService::uploadImage` | `V2/Services/Gallery/Services/GalleryService.php:55` |
| `cache.prefix` | `laravel` | prefixo das chaves `page:*` (limpeza manual em seeder) | `PageCacheMiddleware` | `V2/Seeds/BasicPages/SucesuHomepageSeeder.php:414-416` |
| `module.active:BasicPage` | ativo quando não há flag | desativa o grupo CMS inteiro (`404 Modulo desativado.` em HTML; 404 JSON em AJAX), sem redirect ao dashboard | `V2ServiceProvider::mapModuleRoutes` | `V2/Core/Middleware/ModuleAccessMiddleware.php:47-67`; `V2/Core/Services/ModuleFlagService.php:72-95` |
| permissão `BasicPage` | `[L]` — não há seed de permissão para a chave | valores `none\|read\|write` no `V2Role.permissions`; `write` libera `updateSeo` e o botão/modal de SEO | `V2PermissionMiddleware`; `v2_can`; `@v2can` | `V2/Core/Services/PermissionService.php:40-63`; `V2/Core/Helpers/helpers.php:12-27` |
| `ModuleProvider::getConfig()` | `title='Páginas do Portal'`, `description`, `icon='bi bi-file-earmark-text'`, `position=1`, `active_pattern='cms/paginas-fixas*'`, `route='v2.basicpage.index'`, submódulos “Todas as Páginas” e “Criar Página” (restrito) | entrada no menu e resolução da chave de permissão | `SidebarComposer`; `V2PermissionMiddleware` | `ModuleProvider.php:21-46` |

## 11. Observabilidade, erros e limites

| Evento | Canal / nível | Mensagem | Fonte |
|---|---|---|---|
| Falha ao auditar SEO | `Log::warning` | `[BasicPageCmsController] Falha ao registrar auditoria de updateSeo: {msg}` | `BasicPageCmsController.php:321-323` |
| Falha de upload | `Log::error` | `[BasicPage] Erro ao enviar asset: {msg}` | `BasicPageRuntimeController.php:84` |
| Falha ao criar asset da galeria | `Log::error` | `[BasicPage] Erro ao criar asset da galeria: {msg}` | `BasicPageRuntimeController.php:113` |
| Falha ao remover asset | `Log::error` | `[BasicPage] Erro ao remover asset: {msg}` | `BasicPageRuntimeController.php:143` |
| Falha ao apagar arquivo físico | `Log::warning` | `[BasicPageAssetManager] Falha ao deletar arquivo fisico: {msg}` | `BasicPageAssetManagerService.php:94-96` |
| Falha no catch-up de boot | `Log::warning` | `[BasicPage] Inicializacao de busca ignorada: {msg}` | `V2/Core/Providers/V2ServiceProvider.php:162-165` |
| Falha de indexação | `Log::error` | `[Search] Public Indexing failed: {msg}` (engolida) | `V2/Services/Search/Services/PublicIndexerService.php:54-56` |
| Falha do hook `saved` | nenhuma (indexadores engolem) | — | `Models/BasicPage.php:141-143` |
| Auditoria | `activity_log` (`v2_audit=true`) | descrições `updated`/`created`/`deleted` + props | `V2/Services/ActivityLogger/ActivityLoggerService.php:20-44` |
| Acesso de suporte negado | sem log | `403 Acesso restrito ao suporte técnico autorizado.` | `BasicPageCmsController.php:35-37` |
| Acesso de permissão negado | `Log::warning` | `[V2 > MIDDLEWARE > Permission] ...` + redirect com `Acesso negado. Seu perfil não possui permissão para acessar o módulo: Páginas do Portal` | `V2/Core/Middleware/V2PermissionMiddleware.php:83-103` |

Limites: upload 10 MB; sem throttle; sem paginação (listagem de páginas e de assets); `content` em `text`; sem timeout próprio; `404` não é cacheado; falha de Redis serve sem cache (`PageCacheMiddleware.php:62-65`).

## 12. Seeds e dados iniciais

| Origem | Tabela | Registros | Idempotência | Fonte |
|---|---|---|---|---|
| Migration `create_basic_pages_table` | `v2_basic_pages` | 5 linhas fixas (`homepage`, `lgpd`, `acessibilidade`, `mapa-do-site`, `ouvidoria`) com `content=''` e `metadata.seo.title` curto | não (recria a tabela) | `:37-55` |
| `DATA-SEEDS-BASICPAGES` (`V2/Seeds/BasicPages/*`) | `v2_basic_pages` | linhas “sombra” para `noticias`, `associados`, `convenios`, `agenda`, `fale-conosco`, `parceiros-institucionais` (algumas `is_runtime=true` com HTML completo) | `where slug → update\|insert` | `V2/Seeds/BasicPages/NoticiasSeeder.php:70-106` (padrão repetido nos demais) |
| `SucesuHomepageSeeder` | `v2_basic_pages` + `v2_layout_base_configs` + `v2_basic_page_assets` | zera `content` da homepage, grava SEO SUCESU, popula `landing_sections` e registra imagens via `createFromGallery` | update por slug + `firstOrNew` + dedupe por `gallery_media_id` | `V2/Seeds/BasicPages/SucesuHomepageSeeder.php:164-177,396-424` |
| `ProcessesGalleryImages` (trait) | `v2_gallery_media` + `v2_basic_page_assets` | envia imagens de `assets_sucesu`/`REFERENCE` e chama `registerBasicPageAsset` | slug/dedupe por galeria | `V2/Seeds/BasicPages/ProcessesGalleryImages.php:98-113` |
| `V2ServiceProvider` (boot console) | `v2_public_search_index` + `v2_cms_links_index` | indexa páginas sem linha no índice | `whereNotExists` por `entity_id` | `V2/Core/Providers/V2ServiceProvider.php:145-166` |

Nenhum seeder de permissão cria a chave `BasicPage` neste repositório — o perfil precisa ser configurado em Gestão de Permissões (`[L]`).

## 13. Lacunas

| ID | Lacuna | Onde procurar | Impacto |
|---|---|---|---|
| `LG-BASICPAGE-01` | Não há renderizador que compile Blade a partir de `v2_basic_pages.content`; não foi possível determinar se algum deploy/plugin o faz. | busca por `compileString`/`Blade::render`; pipeline de deploy | Decide se snippets `@include('v2-service-media::components.picture')` funcionam ou aparecem como texto. |
| `LG-BASICPAGE-02` | Não há seed/registro da permissão `BasicPage` (`read`/`write`); não se sabe o conjunto padrão de perfis. | `GestaoPermissoes`, tabela `v2_roles.permissions`, produção | Define quem vê o menu e quem edita SEO; `v2_can` retorna `false` para perfis sem a chave. |
| `LG-BASICPAGE-03` | `config/app.php` declara `locale=pt-br`, mas não existe diretório de traduções no repositório; as mensagens de validação exibidas não puderam ser confirmadas. | `resources/lang`, `lang/`, configuração de `TranslationServiceProvider` | Textos de erro ao usuário (contrato de UI) ficam indeterminados. |
| `LG-BASICPAGE-04` | Comportamento real da dupla codificação de `metadata` em PHP 7.x vs 8.x (warning vs `TypeError`) não foi executado. | ambiente de produção; `php -v` | Define se páginas criadas pelo CMS são editáveis depois. |
| `LG-BASICPAGE-05` | Não existe teste automatizado para o módulo; a única cobertura localizada é de `RedigestContentBlobs` para o serviço de mídia. | `tests/Feature/V2/Services/Media` | Nenhuma rede de segurança para rotas/404/cache. |
| `LG-BASICPAGE-06` | Não foi possível confirmar se `v2_basic_page_assets` é consumida por alguma build/asset pipeline externa (os snippets não são injetados pelo servidor). | `webpack.mix.js`, `V2/layouts/portal/base.blade.php` | Define se a File Library é funcional ou apenas catálogo. |
| `LG-BASICPAGE-07` | Não há indicação de limpeza de cache após salvar página; não se sabe se um job externo (deploy/hook) invalida `page:*`. | `V2/Core/Console/Commands/ClearPageCache.php`; cron | Janela de conteúdo desatualizado no portal. |

## 14. Checklist de paridade

- [ ] `PC-BASICPAGE-01` — `GET /cms/paginas-fixas` autenticado lista todas as linhas de `v2_basic_pages` com badge Runtime/Fallback e link “Ver” correto por tipo de slug. Fonte: `BasicPageCmsController.php:43-100`.
- [ ] `PC-BASICPAGE-02` — `GET /cms/paginas-fixas/editor/assets` **não** é capturado por `/editor/{slug}` e devolve fragmento HTML (sem layout). Fonte: `Routes/cms.php:22-28`.
- [ ] `PC-BASICPAGE-03` — Salvar conteúdo com `layout.showHeader=1` grava `metadata.layout.showHeader=true` e preserva `metadata.seo`. Fonte: `BasicPageCmsController.php:148-158`.
- [ ] `PC-BASICPAGE-04` — Salvar com `content=''` faz `/lgpd` voltar a renderizar o Blade fixo no request seguinte (após limpar cache). Fonte: `BasicPagePortalController.php:56-68,88-95`.
- [ ] `PC-BASICPAGE-05` — Criar página com slug `minha-pagina` faz `GET /minha-pagina` retornar `200` com o conteúdo ecoado **sem** escape. Fonte: `Routes/fallback.php:19-21`; `Views/portal/runtime.blade.php:4`.
- [ ] `PC-BASICPAGE-06` — `GET /Minha-Pagina` e `GET /minha_pagina` retornam `404` sem consultar o banco. Fonte: `Routes/fallback.php:21`.
- [ ] `PC-BASICPAGE-07` — Página existente com `is_runtime=false` não é servida pelo catch-all (404). Fonte: `BasicPagePortalController.php:149`.
- [ ] `PC-BASICPAGE-08` — Toda criação/edição de página produz upsert em `v2_public_search_index` (`module='basicpage'`, `entity_type` FQCN, `slug`, `search_deep_content` sem tags) e em `v2_cms_links_index` com `edit_url`. Fonte: `Models/BasicPage.php:222-254`.
- [ ] `PC-BASICPAGE-09` — Em `php artisan`, páginas sem linha no índice são indexadas; em request web o catch-up **não** roda. Fonte: `V2ServiceProvider.php:145-166`.
- [ ] `PC-BASICPAGE-10` — Upload de CSS grava arquivo em `assets/basicpage/css/…` com visibilidade pública, linha em `v2_basic_page_assets` e `snippet` `<link rel="stylesheet" href="{{ asset('storage/…') }}">`. Fonte: `BasicPageAssetManagerService.php:26-51,169-179`.
- [ ] `PC-BASICPAGE-11` — `assets/from-gallery` com o mesmo `gallery_media_id` duas vezes retorna o mesmo `asset.id` e não cria segunda linha. Fonte: `BasicPageAssetManagerService.php:109-112`.
- [ ] `PC-BASICPAGE-12` — Upload de imagem cria mídia em `v2_gallery_media` com `owner_module='basicpage'`, `is_shared=false` e preenche `gallery_media_id` no asset. Fonte: `BasicPageAssetManagerService.php:54-64`.
- [ ] `PC-BASICPAGE-13` — `POST .../seo/update` com perfil `read`: o `V2PermissionMiddleware` responde antes do controller — `302` de volta com `Operação negada. Seu perfil possui apenas permissão de leitura para o módulo: Páginas do Portal` (HTML) ou `403 Forbidden. Write actions are not allowed for your profile.` (AJAX). O `403 Acesso negado. Ação restrita a operadores com permissão de escrita.` (`BasicPageCmsController.php:287-289`) só é alcançável por quem já tem `write`, logo é defesa redundante. Fonte: `V2/Core/Middleware/V2PermissionMiddleware.php:102-121`; `BasicPageCmsController.php:287-289`.
- [ ] `PC-BASICPAGE-14` — `GET .../editor/{slug}` para usuário CMS que não é o e-mail de suporte responde `403 Acesso restrito ao suporte técnico autorizado.` Fonte: `BasicPageCmsController.php:33-39`.
- [ ] `PC-BASICPAGE-15` — `/lgpd`, `/acessibilidade`, `/mapa-do-site`, `/ouvidoria` aplicam SEO do banco mesmo quando usam o Blade fixo (title/description/OG/JSON-LD). Fonte: `BasicPagePortalController.php:24-50,88-95`.
- [ ] `PC-BASICPAGE-16` — `getLayoutConfig()` da homepage retorna `fullWidthMain=true` e os seis cromados `false` sem metadata salva. Fonte: `Models/BasicPage.php:150-190`.
- [ ] `PC-BASICPAGE-17` — Segunda requisição ao mesmo slug em menos de 5 min retorna `X-Cache-Status: HIT`; salvar a página **não** invalida o cache. Fonte: `PageCacheMiddleware.php:25-59`.
- [ ] `PC-BASICPAGE-18` — Resposta pública do portal traz `Cache-Control: public, max-age=60, must-revalidate` e `Vary: Accept-Encoding`, mas `cms/*` não recebe esses headers. Fonte: `PublicCacheMiddleware.php:47-114`.
- [ ] `PC-BASICPAGE-19` — Slugs de `v2_basic_pages` aparecem no sitemap com prioridade 1.0/daily (homepage) e 0.6/monthly (demais). Fonte: `SeoPortalController.php:149-172`.
- [ ] `PC-BASICPAGE-20` — Remover asset faz soft delete (`deleted_at`) e tenta apagar o arquivo físico sem lançar erro. Fonte: `BasicPageAssetManagerService.php:89-100`.

## 15. Evidências consultadas

- `V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:17-329` (todas as actions, validações, mensagens).
- `V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:1-159` (bind SEO, fallback, runtime).
- `V2/Modules/Layouts/BasicPage/Controllers/BasicPageRuntimeController.php:1-146` (assets, upload, galeria, delete).
- `V2/Modules/Layouts/BasicPage/Models/BasicPage.php:1-255` (fillable/casts, defaults SEO/layout, hook, indexação).
- `V2/Modules/Layouts/BasicPage/Models/BasicPageAsset.php:1-38` (soft delete, UUID).
- `V2/Modules/Layouts/BasicPage/Services/BasicPageAssetManagerService.php:1-186` (contrato de assets e snippets).
- `V2/Modules/Layouts/BasicPage/Migrations/2026_06_29_092300_create_basic_pages_table.php:17-91` e `2026_07_06_000200_create_basic_page_assets_table.php:14-60`.
- `V2/Modules/Layouts/BasicPage/ModuleProvider.php:1-47`; `Routes/cms.php:1-30`; `Routes/web.php:1-21`; `V2/Core/Routes/fallback.php:1-21`.
- `V2/Modules/Layouts/BasicPage/Views/cms/{index,editor,create,preview}.blade.php`; `Views/cms/assets/index.blade.php`; `Views/cms/components/{asset-card,asset-sidebar}.blade.php`; `Views/portal/{runtime,homepage,lgpd,acessibilidade,mapa_site,ouvidoria}.blade.php`.
- `V2/layouts/cms/partials/code-editor.blade.php:1-105`; `V2/layouts/portal/{app-layout,base}.blade.php`; `V2/layouts/portal/metadata.blade.php`; `V2/layouts/portal/metadata/seo.blade.php:1-47`.
- `V2/Core/Providers/V2ServiceProvider.php:60-218,289-335`; `V2/Core/Middleware/{CmsAuthenticate,V2PermissionMiddleware,ModuleAccessMiddleware,PageCacheMiddleware,PublicCacheMiddleware}.php`; `V2/Core/Services/{PermissionService,ModuleFlagService}.php`; `V2/Core/Helpers/helpers.php:12-69`; `config/v2.php:1-14`.
- `V2/Services/{Search,Services/Gallery,Services/Storage,Services/Media,Services/SEO,Services/ActivityLogger}` (DTOs, indexadores, `GalleryService::upload`, `ImageRedigestService::buildPictureSnippet`, `SEOServiceProvider`, `ActivityLoggerService`).
- `V2/Seeds/BasicPages/{NoticiasSeeder,SucesuHomepageSeeder,ProcessesGalleryImages}.php`; `V2/Modules/Layouts/Homepage/{Routes/web.php,Controllers/HomepagePortalController.php}`; `V2/Modules/Support/StructuralLandingEditor/{ModuleProvider.php,ViewComposers/LayoutBaseComposer.php,Services/BlockRenderer.php}`; `V2/Services/LandingSectionsEditor/README.md`.
