# Legislação — Regras e Portabilidade

> **Slug:** `Legislacao` · Regras não óbvias, acoplamentos e armadilhas do
> código em `V2/Modules/Publicacoes/Legislacao`.
>
> Caminhos relativos a `lotep/`.

---

## 1. Regras de negócio não óbvias

### 1.1 Publicação é um predicado, não um estado

```php
public function scopePublished($q)
{
    return $q->where('is_active', true)->where('publication_date', '<=', now());
}
```

(`V2/Modules/Publicacoes/Legislacao/Models/V2Legislation.php:138-141`)

Consequências:

1. **Agendamento gratuito:** basta preencher uma data futura; o item some do
   portal até o instante da comparação.
2. **Data nula equivale a não publicado:** `NULL <= now()` é `NULL`, logo falso.
   Normas importadas sem `publication_date` não aparecem em `/legislacao`,
   embora apareçam no CMS e no filtro de estatísticas.
3. **Não há transição de estado auditada:** mudar a data de um item já publicado
   republica/suspende sem evento semântico (`publicar`, `despublicar`).
4. **Fuso:** `now()` usa o timezone da aplicação; `publication_date` é
   `datetime` sem timezone. O V7 deve usar `timestamptz` e relógio injetado
   (ADR-001).

### 1.2 Slug derivado com colisão não tratada

```php
static::creating(function ($m) {
    if (empty($m->id)) {
        $m->id = \v2_uuid();
    }
    if (empty($m->slug) && !empty($m->title)) {
        $m->slug = Str::slug($m->title);
    }
});
static::updating(function ($m) {
    if ($m->isDirty('title') && !$m->isDirty('slug')) {
        $m->slug = Str::slug($m->title);
    }
});
```

(`Models/V2Legislation.php:82-94`)

- Não existe loop de unicidade nem sufixo. Dois títulos `Decreto 1` geram o
  mesmo slug; a segunda gravação viola `UNIQUE (slug)` e resulta em erro de
  banco (500).
- A importação resolve isso à parte, com sufixo aleatório de 6 caracteres
  (`LegislationCmsController.php:444-451`).
- Ao mudar o título, o slug muda e a URL antiga deixa de existir: não há tabela
  de redirecionamento nem histórico. Citações externas quebram.

### 1.3 Categoria: exclusão com renomeação e desvínculo

`categoriesDestroy` (`LegislationCmsController.php:223-241`):

1. `slug = slug . '-deleted-' . substr(uniqid('', true), -6)` e `save()`.
2. `V2Legislation::where(category_id, id)->update(['category_id' => null])`.
3. `$category->delete()` (soft delete).
4. Auditoria `category_deleted`.

Motivos:

- O `FK ON DELETE SET NULL` só age em `DELETE` físico; soft delete não dispara.
  Sem o passo 2, os itens continuariam apontando para uma categoria invisível:
  a relação `belongsTo` aplica o escopo global de soft delete e devolveria
  `null`, mas o id permaneceria órfão no banco.
- O passo 1 libera o slug para recadastro, já que o índice único é global e não
  parcial por `deleted_at`.

Risco: a renomeação usa `uniqid()` (não determinístico) e pode colidir em tese;
a string final tem tamanho imprevisível, mas o limite de 255 não é verificado.

### 1.4 Unicidade considerando soft-deleted

Três caminhos aplicam `withTrashed()`:

| Caminho | Arquivo:linha | Efeito |
|---|---|---|
| Hook `saving` da categoria | `Models/V2LegislationCategory.php:79-89` | Sufixo `-1`, `-2` até achar slug livre. |
| `categorySlugExists` | `Controllers/Admin/LegislationCmsController.php:494-501` | Rejeita nome duplicado, inclusive apagado (até ser renomeado na exclusão). |
| `uniqueLegislationSlug` | `Controllers/Admin/LegislationCmsController.php:444-451` | Sufixo aleatório para importação. |

O `LegislationRequest` **não** usa `withTrashed()` no `unique` de slug; é uma
inconsistência: o validator diz "livre", mas o banco rejeita.

### 1.5 Importação idempotente por `number` + `year`

`processImportExcel` (`:349-362`):

```php
$existing = $number !== null && $year !== null
    ? V2Legislation::where('number', $number)->where('year', $year)->first()
    : null;
if ($existing) {
    unset($attrs['slug']);
    $existing->update($attrs);
    $this->audit->activity($existing, 'import_updated', ['type' => 'legislation_import', 'title' => $existing->title]);
    return;
}
```

- A chave natural é declarada no schema (`setUniqueColumns(['number','year'])`,
  `Imports/LegislationImportSchema.php:32`), mas **não existe índice único** no
  banco: duas importações concorrentes podem duplicar.
- Sem número **ou** sem ano, a linha sempre cria um novo registro.
- O update **não altera o slug**, preservando URLs; também sobrescreve
  `publication_date`, `summary`, categoria, `is_active` e zera `content` para o
  AST vazio (`legislationImportAttrs`, `:417-435`).
- `is_highlighted` é forçado a `false` no update — importar por cima de um
  registro destacado perde o destaque.
- A categoria é resolvida por nome **ou** slug; se não existir, é criada. A
  busca não usa `withTrashed()`, então uma categoria apagada (com slug
  renomeado) não é encontrada e uma nova é criada.

### 1.6 Anexos: allowlist e magic bytes

`LegislationFileService` (`:25-57`, `:189-242`):

- MIME permitidos: PDF, ZIP (dois rótulos), RAR, DOC, DOCX, ODT, XLS, XLSX.
- `validateMime()` compara `$file->getMimeType()` com a lista; qualquer outro
  tipo lança `RuntimeException`.
- `validateMagicBytes()` lê 16 bytes e compara assinaturas:
  - PDF: `%PDF`;
  - ZIP/DOCX/ODT/XLSX: `P`,`K`;
  - RAR: `Rar!` + `\x1A\x07` ou `Rar!  `;
  - DOC/XLS: assinatura OLE2 `D0 CF 11 E0 A1 B1 1A E1`.
- Se o MIME não tem assinatura mapeada, a checagem é ignorada (fail-open).
- O arquivo só é gravado **depois** das duas validações; o nome é gerado pelo
  `store()` do Laravel, sem usar o nome do cliente no path.

Limitações:

- A assinatura de ZIP é apenas `P`,`K` (2 bytes), então qualquer arquivo que
  comece assim passa como DOCX/ODT/XLSX.
- Não há verificação de conteúdo interno, tamanho descompactado (zip bomb) nem
  antivírus.
- O limite de 50 MB é aplicado em três lugares (request, validação inline e
  `max:51200` no FormRequest), mas não no service.

### 1.7 Download bloqueado para não publicado

```php
$fileRow = V2LegislationFile::whereHas('legislation', function ($q) {
    return $q->published();
})->findOrFail($file);
```

(`Controllers/Portal/LegislationPortalController.php:107-109`)

- Anexo de norma inativa, agendada ou soft-deleted responde 404.
- Se `legislation_id` for `NULL` (FK `SET NULL` após exclusão física), o
  `whereHas` nunca casa: o anexo fica inacessível, mas o arquivo físico pode
  permanecer.
- URL externa é redirecionada com `redirect()->away()`; caminho local é
  validado contra `..` e contra a existência no disco `public`.
- O nome do download usa `original_name` com `/` e `\` substituídos por `_`
  (`:125`), evitando header injection por separador de caminho.

### 1.8 `destroy` só apaga prefixos conhecidos

`LegislationFileService::delete()` (`:148-165`):

```php
$path = (string) $fileRow->file_url;
if (strpos($path, 'legislacao/') === 0 || strpos($path, 'gallery/legislacao/') === 0) {
    Storage::disk('public')->delete($path);
}
$fileRow->delete();
```

- Arquivos em outros prefixos (`V2/assets/demo`, uploads antigos, URLs externas)
  são preservados — decisão deliberada para não apagar ativos compartilhados.
- Efeito colateral: lixo no storage cresce quando o caminho não segue o padrão,
  e o banco perde a referência (soft delete) sem forma de localizar o arquivo.
- A checagem é por prefixo de string, não por resolução de caminho: um path
  como `legislacao/../outro/arquivo` passaria no teste e apontaria para fora,
  embora esse valor não seja produzido pelo upload (só por importação/dados
  manuais).

## 2. Acoplamentos

| Direção | Módulo/serviço | Contrato |
|---|---|---|
| Depende | `V2/Services/ContentRichEditor` | `$renderer->toHtml($item->content)` no portal. |
| Depende | `V2/Services/SEO` | `setTitle`, `setDescription`, `setCanonical` (falhas silenciosas). |
| Depende | `V2/Services/ExcelImporter` | Preview, process e template. |
| Depende | `Support/MigracaoLegada` | Importador V1 grava nas tabelas do módulo. |
| É usado por | `Infraestrutura/Estatisticas` | `LegislacaoDataSource` consulta as tabelas por SQL. |
| Compartilha padrão | `Publicacoes/Noticias`, `Licitacoes` | Estrutura de anexos, modais e editor. |

Não há dependência de `CategoryContent` da V1: o módulo é isolado por desenho
(comentário em `LegislationCmsController.php:147-150`).

## 3. Armadilhas de implementação

| Armadilha | Onde | Impacto |
|---|---|---|
| HTML cru renderizado | `Views/portal/show.blade.php:44-49` + `ContentRichHtmlFreeRenderer::render` | XSS armazenado se o AST contiver `attrs.html` malicioso. |
| URL de link sem allowlist de esquema | `V2/Services/ContentRichEditor/Parser/Marks/LinkRenderer.php:30-36` | `javascript:` passa; o atributo é apenas `htmlspecialchars`. |
| `str_limit()` | `Views/portal/index.blade.php:88` | Helper removido no Laravel 9+; V7 usa `Str::limit`. |
| JS inline e `onclick` | `form.blade.php:71-113`, `categories.blade.php:82-91`, `attachments.blade.php:39` | Incompatível com CSP sem inline (ADR-022). |
| `addslashes()` em atributo | `attachments.blade.php:39` | Escape inadequado para contexto HTML; aspas simples/duplas e `</script>` não são tratados. |
| Query em loop | `LegislationFileService::reorder` (`:176-181`) | Um `find` + um `update` por anexo; aceitável para poucos arquivos, mas é N+1 de escrita. |
| `whereHas` sem índice composto | portal index | Com volume alto, o filtro por slug de categoria pode varrer a tabela. |
| `ILIKE %termo%` | `scopeSearch` | Sem índice de trigram/GIN; full scan em acervo grande. |
| Sem paginação em estatísticas | `LegislacaoDataSource::latestTable` | `limit(10)` explícito; ok. |
| Uploads concorrentes | `order = files()->count()` | Dois uploads simultâneos podem receber a mesma ordem. |
| Importação concorrente | ausência de unique `number+year` | Duplicatas sob concorrência. |
| Auditoria sem snapshot | `destroy` chama `activity($item,'deleted',['title'=>$t])` | A trilha guarda apenas o título, não o conteúdo/anexos excluídos. |
| Silêncio de SEO | `try/catch` vazio em `show()` (`:70-77`) | Falha de SEO não é observável. |
| `publication_date` sem timezone | migration e cast `datetime` | Comparação com `now()` depende do timezone do app; migração de dados precisa normalizar. |

## 4. Divergências e duplicação

- O módulo declara `file_url` (documento principal) **e** `v2_legislation_files`
  (anexos). A duplicação é histórica: a tabela de anexos nasceu na importação V1
  para preservar todos os PDFs, mantendo o `file_url` como principal.
- `V2LegislationFile::resolveMediaUrl()` duplica a lógica de
  `LicitacaoAnexo::$url` (comentário no próprio código, `:105-109`).
- Os componentes de UI (`status-badge`, `filter-card`, `table`,
  `excel-transpiler`) pertencem a `v2-cms`/services; o módulo só os consome.
- O conceito de "publicado" aparece em dois lugares: `scopePublished` no portal e
  a checagem manual de `is_active` no status do CMS/estatísticas. Não há um
  enum/estado único.

## 5. Portabilidade — o que sobrevive intacto

| Regra | Portável? | Observação |
|---|---|---|
| Publicação = ativo + data ≤ agora | Sim | Vira coluna de estado derivada ou escopo explícito com `timestamptz`. |
| Agendamento por data | Sim | Requisito de produto; manter. |
| Slug derivado do título | Sim, com correção | Precisa de unicidade real e histórico/redirect. |
| Categoria com soft delete e slug liberado | Parcial | No V7, arquivar não deve renomear slug silenciosamente; usar unicidade parcial `WHERE deleted_at IS NULL`. |
| Importação idempotente `number+year` | Sim | Exige índice único parcial e `upsert` transacional. |
| Allowlist de MIME + magic bytes | Sim | Migrar para `Media`/validação nativa (`mimes`, `finfo`) e testar conteúdo. |
| Download condicionado à publicação | Sim | Manter, com policy e rota assinada se necessário. |
| Conteúdo em AST TipTap | Sim | Já é o padrão do V7 (ADR-028), mas `content` deve ser `jsonb`, não `longText`. |
| Versionamento/revogação | **Não existe** | Precisa ser desenhado (ver `08-GAPS-E-DIVIDAS.md`). |

## 6. Dados sensíveis e LGPD

O módulo não trata dados pessoais. Os riscos são de integridade e
disponibilidade do ato oficial: alteração silenciosa de texto publicado, perda
de anexo por prefixo não reconhecido e URL quebrada por regeneração de slug.
No V7, a trilha de auditoria (ADR-035) deve registrar antes/depois do texto e
dos anexos, e a norma consolidada deve ser imutável por versão.
