# Legislação — Migração Moderna (V7)

> **Origem:** `lotep/V2/Modules/Publicacoes/Legislacao` (Laravel 5.5 / PHP 7.4)
> **Destino:** V7 — Laravel 12 / PHP 8.4, DDD-lite (`src/{core,services,layout,modules}`),
> PostgreSQL 18, sem traits/abstract/generics.
>
> Caminhos com prefixo `V2/` são relativos a `lotep/`; caminhos com prefixo `src/` ou `wiki/` são
> relativos a este repositório (V7).

---

## 1. Módulo de destino

```
src/modules/Publicacoes/Legislacao/
├── ModuleProvider.php
├── MODULE.md
├── Database/
│   ├── Migrations/            # schema legislacao + tabelas
│   └── Models/                # Legislacao, LegislacaoCategoria, LegislacaoAnexo, LegislacaoVersao
├── Logic/
│   ├── Actions/               # SaveLegislacao, PublicarLegislacao, ArquivarLegislacao
│   ├── Controllers/Cms/       # invocáveis, sem closure
│   ├── Controllers/Portal/
│   ├── DTOs/
│   ├── Observers/
│   └── Requests/
├── Routes/{portal,cms,api}.php
├── Layout/Views/{cms,portal,partials}
├── Lang/{pt_BR,en,es,fr}
├── Seo/LegislacaoSitemapSource.php
└── Test/
```

Grupo `Publicacoes` é apenas organizacional (ADR-014); o token canônico é
`legislacao`.

## 2. Schema proposto

Decisões transversais:

- Schema dedicado `legislacao` (padrão dos módulos V7, ex. `noticias.noticias`).
- `uuidv7()` como default da PK; `timestamptz`; `jsonb` para conteúdo e metadados
  (ADR-017).
- Estado explícito em vez de dois booleanos: `situacao` (`rascunho`,
  `publicado`, `arquivado`) + `publicado_em` + `agendado_para`.
- Versionamento e consolidação como tabelas próprias (requisito do backlog
  `wiki/backlog/modulo-legislacao.md:19`).
- Unicidade de `numero+ano` via índice único parcial; unicidade de slug por
  índice parcial `WHERE deleted_at IS NULL`.

### 2.1 Migration principal (exemplo ilustrativo)

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    private const string SCHEMA = 'legislacao';

    public function up(): void
    {
        DB::statement("SET lock_timeout = '3s';");
        DB::statement('CREATE SCHEMA IF NOT EXISTS '.self::SCHEMA.';');
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');

        if (Schema::hasTable('legislacao')) {
            DB::statement('RESET search_path;');

            return;
        }

        DB::beginTransaction();

        try {
            Schema::create('categorias', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->text('nome');
                $table->text('slug');
                $table->text('descricao')->nullable();
                $table->unsignedInteger('ordem')->default(0);
                $table->boolean('ativa')->default(true);
                $table->jsonb('metadata')->nullable();
                $table->timestampsTz();
                $table->softDeletesTz();
            });

            Schema::create('legislacao', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->text('tipo');                       // lei | decreto | portaria | resolucao
                $table->text('numero')->nullable();
                $table->smallInteger('ano')->nullable();
                $table->text('titulo');
                $table->text('slug');
                $table->text('ementa')->nullable();
                $table->text('orgao')->nullable();
                $table->text('situacao')->default('rascunho');
                $table->jsonb('content')->nullable();        // AST TipTap (ADR-028)
                $table->jsonb('metadata')->nullable();       // seo, importacao, origem
                $table->uuid('categoria_id')->nullable();
                $table->timestampTz('publicado_em')->nullable();
                $table->timestampTz('agendado_para')->nullable();
                $table->boolean('destaque')->default(false);
                $table->unsignedInteger('lock_version')->default(0);
                $table->timestampsTz();
                $table->softDeletesTz();

                $table->foreign('categoria_id')->references('id')->on('categorias')
                    ->nullOnDelete();
            });

            Schema::create('legislacao_anexos', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->uuid('legislacao_id');
                $table->text('media_key');                   // chave do Services\Media (ADR-026)
                $table->text('titulo')->nullable();
                $table->text('descricao')->nullable();
                $table->unsignedInteger('posicao')->default(0);
                $table->boolean('visualizar_pdf')->default(false);
                $table->text('nome_original')->nullable();
                $table->text('mime_type')->nullable();
                $table->unsignedBigInteger('tamanho_bytes')->nullable();
                $table->text('checksum_sha256')->nullable();
                $table->timestampsTz();
                $table->softDeletesTz();

                $table->foreign('legislacao_id')->references('id')->on('legislacao')
                    ->cascadeOnDelete();
            });

            Schema::create('legislacao_versoes', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->uuid('legislacao_id');
                $table->unsignedInteger('versao');
                $table->jsonb('content');
                $table->text('ementa')->nullable();
                $table->timestampTz('vigencia_inicio');
                $table->timestampTz('vigencia_fim')->nullable();
                $table->text('motivo')->nullable();
                $table->uuid('ato_alterador_id')->nullable();
                $table->timestampsTz();

                $table->foreign('legislacao_id')->references('id')->on('legislacao')
                    ->cascadeOnDelete();
            });

            DB::statement(
                'CREATE UNIQUE INDEX legislacao_numero_ano_uq ON legislacao (numero, ano)
                 WHERE numero IS NOT NULL AND ano IS NOT NULL AND deleted_at IS NULL;'
            );
            DB::statement(
                'CREATE UNIQUE INDEX legislacao_slug_uq ON legislacao (slug)
                 WHERE deleted_at IS NULL;'
            );
            DB::statement(
                'CREATE UNIQUE INDEX categorias_slug_uq ON categorias (slug)
                 WHERE deleted_at IS NULL;'
            );
            DB::statement(
                'CREATE UNIQUE INDEX legislacao_versoes_uq ON legislacao_versoes (legislacao_id, versao);'
            );
            DB::statement(
                'CREATE INDEX legislacao_publicadas_idx ON legislacao (publicado_em DESC)
                 WHERE deleted_at IS NULL AND situacao = \'publicado\';'
            );
            DB::statement(
                'CREATE INDEX legislacao_content_gin ON legislacao USING gin (content jsonb_path_ops);'
            );

            DB::commit();
        } catch (Throwable $e) {
            DB::rollBack();
            throw $e;
        }

        DB::statement('RESET search_path;');
    }

    public function down(): void
    {
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');
        Schema::rename('legislacao', 'backup_legislacao_'.date('Y_m_d_His'));
        DB::statement('RESET search_path;');
    }
};
```

Observações de projeto:

- O `down()` renomeia apenas a tabela principal; as demais devem seguir o mesmo
  padrão de `backup_<tabela>_<timestamp>` (ADR-017), nunca `DROP`.
- O índice GIN em `content` serve a `jsonb_path_ops` para filtros pontuais; a
  busca textual completa deve usar coluna gerada `search_vector` + GIN
  (ADR-007), alimentada por `titulo`, `ementa`, `numero` e texto extraído do AST.
- `cascadeOnDelete` em anexos/versões é aceitável porque o V7 **arquiva** (soft
  delete) e o hard delete deve ser restrito ao suporte/expurgo auditado.

## 3. Modelos

| Model | Tabela | Casts/regras |
|---|---|---|
| `Legislacao` | `legislacao.legislacao` | `content` array (AST), `metadata` array, `publicado_em`/`agendado_para` datetime, `situacao` enum PHP, `lock_version` int. |
| `LegislacaoCategoria` | `legislacao.categorias` | `ativa` bool, `ordem` int, `metadata` array. |
| `LegislacaoAnexo` | `legislacao.legislacao_anexos` | `visualizar_pdf` bool, `posicao` int, `tamanho_bytes` int. |
| `LegislacaoVersao` | `legislacao.legislacao_versoes` | `content` array, vigências datetime; **sem update/delete** na aplicação. |

Regras de model:

- `HasUuids` é trait nativa do framework, permitida; a restrição do projeto é
  contra **traits próprias** que escondem lógica.
- Escopo `publicadas()`: `situacao = 'publicado' AND publicado_em <= now() AND
  deleted_at IS NULL`. Diferente do legado, `publicado_em` é explicitamente
  preenchido pelo ato de publicar; **não** usar data nula como "não publicado"
  silencioso.
- Observer `LegislacaoObserver`:
  - gera `slug` determinístico a partir de `titulo` com sufixo estável quando
    houver colisão;
  - abre versão em `legislacao_versoes` sempre que `content`/`ementa` mudam em
    registro já publicado;
  - impede edição direta de registro `arquivado` (exceto restaurar);
  - registra `AuditWriter` (ADR-035) com antes/depois.
- Nenhum model usa `SoftDeletes` como mecanismo de **negócio**: arquivar é
  transição de `situacao` + `deleted_at` apenas para rascunhos descartados.

## 4. Requests e validação

`SaveLegislacaoRequest` (exemplo ilustrativo):

```php
'tipo' => ['required', Rule::in(['lei', 'decreto', 'portaria', 'resolucao'])],
'numero' => ['nullable', 'string', 'max:50'],
'ano' => ['nullable', 'integer', 'between:1900,2100'],
'titulo' => ['required', 'string', 'max:500'],
'slug' => ['nullable', 'string', 'max:255'],
'ementa' => ['nullable', 'string', 'max:2000'],
'orgao' => ['nullable', 'string', 'max:255'],
'categoria_id' => ['nullable', 'uuid', Rule::exists('legislacao.categorias', 'id')->whereNull('deleted_at')],
'publicado_em' => ['nullable', 'date'],
'agendado_para' => ['nullable', 'date', 'after:now'],
'content' => ['nullable', 'array'],
'content.type' => ['required_with:content', Rule::in(['doc'])],
'destaque' => ['boolean'],
'anexos' => ['nullable', 'array'],
'anexos.*' => ['file', 'max:51200', 'mimes:pdf,zip,doc,docx,odt,xls,xlsx'],
```

Validação de MIME deve ser **dupla**: regra `mimes` (extensão/MIME do cliente) e
`Services\Media` com `finfo` no servidor — o legado já fazia magic bytes e não
deve regredir.

## 5. Policies e autorização

`LegislacaoPolicy` (ou closures de `Gate`) com o vocabulário ADR-033:

| Ação | Token | Regra |
|---|---|---|
| Ver | `legislacao.ver` | Acesso ao CMS e à listagem administrativa. |
| Editar | `legislacao.editar` | Criar/editar rascunho e metadados; **não** publica. |
| Aprovar | `legislacao.aprovar` | Publicar, despublicar e editar conteúdo já publicado (gera versão). |
| Arquivar | `legislacao.arquivar` | Arquivar/restaurar. |

O `ModuleProvider::config()` declara `'permissions' => Permission::ACTIONS`.
Nenhuma checagem duplicada no controller além de `Permission::authorize('Legislacao', Permission::EDIT)`.
O portal não usa policy: consulta apenas `publicadas()`.

## 6. Rotas REST propostas

| Método | URI | Nome | Acesso |
|---|---|---|---|
| GET | `/legislacao` | `legislacao.index` | pública |
| GET | `/legislacao/{slug}` | `legislacao.show` | pública |
| GET | `/legislacao/anexos/{anexo}/download` | `legislacao.anexo.download` | pública, condicionada a `publicadas()` |
| GET | `/api/legislacao` | `legislacao.api.index` | pública, CORS aberto (ADR-018) |
| GET | `/api/legislacao/{id}` | `legislacao.api.show` | pública |
| GET | `/cms/legislacao` | `legislacao.cms.index` | `legislacao.ver` |
| GET | `/cms/legislacao/criar` | `legislacao.cms.create` | `legislacao.editar` |
| POST | `/cms/legislacao` | `legislacao.cms.store` | `legislacao.editar` |
| GET | `/cms/legislacao/{id}/editar` | `legislacao.cms.edit` | `legislacao.editar` |
| PUT | `/cms/legislacao/{id}` | `legislacao.cms.update` | `legislacao.editar` |
| POST | `/cms/legislacao/{id}/publicar` | `legislacao.cms.publish` | `legislacao.aprovar` |
| POST | `/cms/legislacao/{id}/despublicar` | `legislacao.cms.unpublish` | `legislacao.aprovar` |
| DELETE | `/cms/legislacao/{id}` | `legislacao.cms.archive` | `legislacao.arquivar` |
| POST | `/cms/legislacao/{id}/restaurar` | `legislacao.cms.restore` | `legislacao.arquivar` |
| POST | `/cms/legislacao/{id}/anexos` | `legislacao.cms.anexos.store` | `legislacao.editar` |
| PUT | `/cms/legislacao/{id}/anexos/{anexo}` | `legislacao.cms.anexos.update` | `legislacao.editar` |
| DELETE | `/cms/legislacao/{id}/anexos/{anexo}` | `legislacao.cms.anexos.destroy` | `legislacao.editar` |
| PUT | `/cms/legislacao/{id}/anexos/ordem` | `legislacao.cms.anexos.order` | `legislacao.editar` |

Importação pode permanecer no CMS (`/cms/legislacao/importar/{template,preview,process}`),
idealmente como Job + streaming, com auditoria por lote.

## 7. API Resources

`LegislacaoResource` deve expor:

| Campo | Observação |
|---|---|
| `id`, `tipo`, `numero`, `ano`, `titulo`, `slug`, `ementa`, `orgao` | Dados de identificação. |
| `situacao`, `publicado_em`, `agendado_para` | Estado e datas ISO-8601 com timezone. |
| `content` | AST JSON (nunca HTML serializado). |
| `versao_atual` | Número e vigência. |
| `anexos[]` | `id`, `titulo`, `descricao`, `visualizar_pdf`, `url` estável, `mime`, `tamanho`. |
| `links.canonical`, `links.download` | URLs estáveis para citação. |
| `categoria` | `{id, nome, slug}`. |

Não expor `metadata` cru (pode conter dados internos de importação).

## 8. Views modernas

- **Portal index:** componente de filtros com `q`, `tipo`, `categoria`, `ano`,
  intervalo; lista com paginação nativa e `aria-live` no contador.
- **Portal show:** título/ementa, bloco de metadados, seletor de versão
  ("texto original" × "consolidado"), texto via componente
  `<x-service-rich-text::render :ast="$versao->content" />`, anexos com `<x-service-media::document>`
  ou link de download assinado.
- **CMS index:** tabela com badges de situação, filtros, ações condicionadas por
  `Permission`.
- **CMS form:** TipTap empacotado no Vite, sem script inline; upload de anexos
  em componente com drag-and-drop real e acessível por teclado.
- **Categorias:** modal ou página dedicada, sempre com slug visível e aviso de
  impacto ao arquivar.

## 9. Storage, fila e cache

| Aspecto | Decisão |
|---|---|
| Anexos | `Services\Media` + `Gallery` (ADR-026): PDF/A quando cabível, AVIF/WebP para imagens, checksum SHA-256, chave opaca no banco em vez de URL. |
| Download | Rota com policy; `Content-Disposition: attachment` para não-PDF e para PDF quando `visualizar_pdf = false`; `inline` quando marcado. |
| Cache | Página pública com ETag/Cache-Control (ADR-022); invalidação ao publicar/despublicar/editar versão. |
| Busca | `search_vector` gerado + GIN (ADR-007); filtros por tipo/ano/órgão indexados. |
| Fila | Importação e geração de derivados de mídia em Job; notificação de falha por evento. |
| Ativação | Dado em `core_module_discovery_settings`; guard `CheckModuleActivation` (ADR-034). |
| Auditoria | `AuditWriter` imutável com antes/depois e `integrity_hash` (ADR-035). |

## 10. Riscos e equivalência funcional

| Risco | Mitigação |
|---|---|
| Perder a semântica "data futura = agendado" | `agendado_para` + `situacao` explícitos; portal filtra `publicado_em <= now()`. |
| Migrar `content` longText (JSON serializado) para `jsonb` | Validar JSON por linha; linhas inválidas viram rascunho com `metadata.migracao_erro`. |
| Perder anexos fora dos prefixos conhecidos | Inventariar `file_url`/`file_url` de anexos; copiar para o `Media` antes de desligar o legado. |
| URLs quebradas ao trocar slug | Tabela de redirecionamento 301 (`slug_historico`) e canonical estável por id. |
| Duplicidade `number+year` | Índice único parcial + `upsert` idempotente; relatório de conflitos na migração. |
| Texto publicado ser editado sem rastro | Versão imutável a cada alteração pós-publicação; edição direta só em rascunho. |
| XSS via AST | Renderer com allowlist de nós/marks e de esquemas de URL; proibir nó de HTML livre. |
| Fuso e data nula | `timestamptz` obrigatório em `publicado_em`; itens sem data viram rascunho explícito. |

O que **não pode mudar**: a URL pública continua `/legislacao/{slug}`; o
cidadão continua vendo apenas ativos e não futuros; o download continua
bloqueado para não publicado; a busca continua cobrindo título, ementa/resumo e
número.
