# Requerimentos — Plano de Migração para Laravel Moderno

> **Alvo:** PHP 8.x + Laravel 11/12 (v7)
> **Origem:** `v6` (Laravel 5.5.50, PostgreSQL 10)
> **Slug do módulo:** `requerimentos`

---

## 1. Diagnóstico: o que há que reconstruir

Na v6 o módulo é “conteúdo genérico + view SQL”. Não há model, migration,
controller ou request próprio. Isso gera três problemas estruturais:

1. O **requerente** mora em `contents.councilmen_id`, coluna genérica usada por
   um único módulo.
2. A listagem do CMS depende de uma **view SQL** mantida por quatro migrations
   que precisam ser atualizadas manualmente a cada mudança de layout.
3. O portal e o CMS aplicam **regras diferentes** (categorias filhas diretas ×
   árvore inteira; data futura; `status`), sem um único lugar que descreva o
   domínio.

A v7 deve introduzir um **modelo de domínio próprio**, mantendo o
comportamento observável.

## 2. Schema de migração Laravel

### 2.1 Tabela `requerimentos`

```php
// database/migrations/2026_xx_xx_create_requerimentos_table.php
Schema::create('requerimentos', function (Blueprint $table) {
    $table->id();
    $table->foreignId('requerente_id')->nullable()
          ->constrained('councilmen')->nullOnDelete();      // vereador requerente
    $table->foreignId('category_id')
          ->constrained('category_contents')->cascadeOnDelete();
    $table->string('title', 120);
    $table->string('slug', 191)->unique();
    $table->longText('body');                              // texto integral (HTML)
    $table->timestamp('published_at')->nullable();          // data/hora de publicação
    $table->enum('status', ['rascunho', 'publicado', 'arquivado'])
          ->default('rascunho');
    $table->unsignedInteger('views')->default(0);           // sucessor de contents.clicks
    $table->json('metadata')->nullable();                   // ex.: número do protocolo
    $table->timestamps();
    $table->softDeletes();

    $table->index(['category_id', 'published_at']);
    $table->index('published_at');
});
```

### 2.2 Migração dos dados (a partir de `contents`)

```php
DB::statement("
    INSERT INTO requerimentos
        (requerente_id, category_id, title, slug, body, published_at, status, views, created_at, updated_at)
    SELECT
        c.councilmen_id,
        c.category_contents_id,
        c.title,
        c.slug,
        c.text,
        c.publication_date,
        CASE WHEN c.status THEN 'publicado' ELSE 'rascunho' END,
        COALESCE(c.clicks, 0),
        c.created_at,
        c.updated_at
    FROM contents c
    JOIN category_contents cc ON cc.id = c.category_contents_id
    WHERE cc.slug = 'requerimentos'
       OR cc.category_contents_id = (SELECT id FROM category_contents WHERE slug = 'requerimentos')
");
```

Cuidados:

- O `slug` precisa ser **único globalmente** (a v6 garantia isso via
  `str_slug(título)` + desambiguação `{slug}-{id}` / `pub{id}-{slug}`); na v7,
  use `Str::slug()` + `slugify()` do pacote `spatie/laravel-sluggable` ou um
  `suffixedSlug()` próprio, com índice unique e retry.
- `contents.clicks` vira `requerimentos.views`.
- Conteúdos **futuros** (`published_at > now`) continuam na base com
  `status = 'rascunho'` ou `published_at` no futuro — decidir com o negócio
  (ver `08-GAPS-E-DIVIDAS.md`).

### 2.3 Anexos

Manter `archives` (tabela genérica) ou criar `requerimento_attachments` com
`requerimento_id`, `path`, `extension`, `mime_type`, `view_pdf` (bool),
`highlight` (bool), `description`, `size`. A v7 deve **não** duplicar a lógica
de `highlight`/`view_pdf` em flags; uma coluna `kind` (`attachment|destaque|pdf_principal`)
é mais limpa.

### 2.4 Fim da view SQL

A view `requerimentos` deve ser removida após a migração
(`DROP VIEW IF EXISTS requerimentos`), e o DataTable do CMS passa a consultar a
tabela. Se houver outros consumidores da view, eles devem ser migrados junto
(verificação: `grep -rn "DB::table('requerimentos')"` — hoje só o
`DatatableController`).

## 3. Modelos

```php
// app/Models/Requerimento.php
class Requerimento extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'requerente_id', 'category_id', 'title', 'slug', 'body',
        'published_at', 'status', 'views', 'metadata',
    ];

    protected $casts = [
        'published_at' => 'datetime',
        'views' => 'integer',
        'metadata' => 'array',
    ];

    public function requerente(): BelongsTo
    {
        return $this->belongsTo(Councilman::class, 'requerente_id');
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(CategoryContent::class, 'category_id');
    }

    public function attachments(): HasMany
    {
        return $this->hasMany(RequerimentoAttachment::class);
    }

    public function scopePublicados(Builder $q): Builder
    {
        return $q->where('status', 'publicado')
                 ->where(fn ($s) => $s->whereNull('published_at')->orWhere('published_at', '<=', now()));
    }
}
```

- `status` como **enum** (`App\Enums\RequerimentoStatus`:
  `Rascunho`, `Publicado`, `Arquivado`) com `cast` — a v6 usa `contents.status`
  booleano genérico.
- `published_at` como `datetime` com timezone explícito (a v6 usa
  `timestamp(0) without time zone` e faz `::timestamp with time zone` na view —
  fonte de erro de fuso).

## 4. Form Requests

```php
// app/Http/Requests/StoreRequerimentoRequest.php
class StoreRequerimentoRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('requerimentos.create');
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'body' => ['required', 'string'],
            'category_id' => ['required', 'integer', 'exists:category_contents,id'],
            'requerente_id' => ['nullable', 'integer', 'exists:councilmen,id'],
            'published_at' => ['nullable', 'date'],
            'attachments' => ['nullable', 'array'],
            'attachments.*' => ['file', 'mimes:jpg,jpeg,png,bmp,gif,rar,zip,doc,docx,pdf,odt,xls,xlsx,xlsb,xslm,ppt,pptx,ods,wav,mp3,wma,mp4,mpg4,mkv,flv', 'max:128000'],
        ];
    }
}
```

Um `UpdateRequerimentoRequest` espelha o anterior, com `Rule::unique('requerimentos','slug')`
ignorando o registro atual.

Regras herdadas da v6 que **devem** ser preservadas: `title` obrigatório
(máx. 120), `text` obrigatório, `category_contents_id` obrigatório,
`councilmen_id` opcional mas existente, extensões de anexo limitadas.

## 5. Policies

```php
// app/Policies/RequerimentoPolicy.php
class RequerimentoPolicy
{
    public function viewAny(User $user): bool      { return $user->can('requerimentos.index'); }
    public function view(User $user): bool          { return true; }   // público
    public function create(User $user): bool        { return $user->can('requerimentos.create'); }
    public function update(User $user): bool        { return $user->can('requerimentos.edit'); }
    public function delete(User $user): bool        { return $user->can('requerimentos.delete'); }
    public function deleteAny(User $user): bool     { return $user->can('requerimentos.delete'); }
}
```

- O portal é público: a policy `view` retorna `true` (não exige papel).
- A granularidade atual (`requerimentos.create|edit|delete|restore|inactivate`)
  é mantida; planejar a carga das permissões para o papel `administrador`,
  que hoje **não** possui nenhuma delas.

## 6. Endpoints REST/JSON

### 6.1 API pública

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/requerimentos` | Lista paginada, filtros `year`, `month`, `q`, `category` |
| GET | `/api/requerimentos/{slug}` | Detalhe |
| GET | `/api/requerimentos/meta/anos` | Anos disponíveis (substitui o `EXTRACT(YEAR ...)` da v6) |
| GET | `/api/requerimentos/meta/meses?ano=` | Meses de um ano |

Exemplo de resposta (API Resource):

```json
{
  "data": [
    {
      "id": 128,
      "titulo": "Requerimento de informação sobre obras",
      "requerente": { "id": 7, "nome": "Maria Souza", "partido": "PSD" },
      "categoria": { "id": 38, "nome": "Requerimento de Informação", "slug": "req-informacao" },
      "data_publicacao": "12/03/2026",
      "published_at": "2026-03-12T14:30:00-03:00",
      "attachments": [
        { "nome": "requerimento.pdf", "url": "https://.../download", "tipo": "pdf" }
      ]
    }
  ],
  "meta": { "current_page": 1, "per_page": 12, "total": 87 }
}
```

### 6.2 Rotas web (portal)

| Método | URI | Nome sugerido |
|---|---|---|
| GET | `/requerimentos` | `requerimentos.index` (redireciona para a primeira categoria, como hoje) |
| GET | `/requerimentos/{category:slug}` | `requerimentos.show` |
| GET | `/requerimentos/{category:slug}/{requerimento:slug}` | `requerimentos.view` |

Mantém-se o padrão de URL para não quebrar links indexados.

### 6.3 Rotas de administração

| Método | URI | Nome |
|---|---|---|
| GET | `/admin/requerimentos` | `admin.requerimentos.index` (DataTable) |
| GET | `/admin/requerimentos/create` | `admin.requerimentos.create` |
| POST | `/admin/requerimentos` | `admin.requerimentos.store` |
| GET | `/admin/requerimentos/{id}/edit` | `admin.requerimentos.edit` |
| PUT | `/admin/requerimentos/{id}` | `admin.requerimentos.update` |
| DELETE | `/admin/requerimentos/{id}` | `admin.requerimentos.destroy` |
| POST | `/admin/requerimentos/bulk-delete` | `admin.requerimentos.bulk-destroy` |

## 7. Views e componentes Blade

- Extrair o bloco de filtros/Ajax **uma única vez** para um componente Blade
  (por exemplo `<x-requerimentos.filters />`) e um componente de script
  (`<x-requerimentos.ajax-script />`), resolvendo a duplicação dos 6 temas
  (armadilha A3).
- Substituir `str_limit()` por `Str::limit()`.
- Substituir o Ajax que devolve HTML por **DataTables/AJAX JSON** (ou
  `fetch` + `htmx`), consumindo o `API Resource` — o portal deixa de depender
  do render de Blade no servidor para filtrar.
- Mover a lista de extensões de download para uma constante
  (`RequerimentoAttachment::DOWNLOADABLE_EXTENSIONS`) compartilhada entre
  validação e view.
- Envolver o texto do requerimento em um sanitizador (HTML Purifier) — hoje
  é `{!! $content->text !!}` sem escape.

## 8. Fila, cache e storage

| Item | Proposta |
|---|---|
| Fila | Processamento de anexos (geração de miniatura, conversão para PDF) em `jobs/ProcessAttachment.php` |
| Cache | `Cache::remember` nos seletores de ano/mês (mudam pouco); invalidar ao publicar |
| Storage | `storage/app/public/requerimentos/{id}/` com `Storage::disk('public')` e `php artisan storage:link` |
| Índice de busca | Para volumes grandes, considerar Scout + Algolia/Meilisearch no lugar do `ilike` com `translate()` |

## 9. Testes automatizados

| Tipo | Alvo |
|---|---|
| Feature (portal) | redirecionamento do `index`, listagem unificada, filtros, paginação, conteúdo futuro oculto, incremento de `views` |
| Feature (API) | filtros `year`/`month`/`q`, formato do resource, `404` para slug inexistente |
| Feature (admin) | criação com/sem requerente, validações, exclusão em lote, flags de permissão |
| Unit | `Requerimento::scopePublicados`, `suffixedSlug()`, normalização de busca |

Estratégia completa em `07-TESTES.md`.

## 10. Riscos e decisões de equivalência funcional

### 10.1 O que **não pode** mudar

| Item | Motivo |
|---|---|
| URL `/requerimentos/{categoria}/{slug}` | Links publicados e indexados |
| Unificação das categorias na listagem | Comportamento percebido pelo cidadão (trocar de aba não muda a lista) |
| Filtros por ano, mês e texto com normalização de acentos | Uso principal do módulo |
| Ocultação de conteúdo com data futura | Evita publicação antecipada indevida |
| Incremento de contador a cada visualização | Métrica institucional |
| Download dos anexos e embed do PDF principal | Entrega do documento ao cidadão |
| Campos obrigatórios: título, texto, categoria, data | Integridade do acervo |
| Requerente opcional | contemplated: há requerimentos publicados sem vereador vinculado |

### 10.2 Riscos

| Risco | Mitigação |
|---|---|
| Colisão de slugs durante a migração | Gerar slug único por conteúdo (não por módulo) e registrar os conflitos para revisão manual |
| Divergência entre portal e CMS na definição de “publicado” | Definir `scopePublicados` único e usá-lo nos dois lados |
| Contagens de `contents.clicks` | Copiar para `requerimentos.views` e zerar `contents.clicks` dos migrados |
| Dependência do `highlight`/`view_pdf` genéricos | Mapear para `kind` na nova tabela de anexos |
| Requerentes removidos logicamente | Preservar o nome em `metadata.requerente_nome` ou manter o vínculo com `nullOnDelete` |
| Volume da tabela `contents` | O módulo deixa de poluir a tabela genérica; planejar a limpeza após a migração |

### 10.3 Decisões em aberto para o negócio

1. O conteúdo de sub-subcategorias deve aparecer no portal? (hoje: só no CMS).
2. Rascunhos devem aparecer no CMS com filtro de status? (hoje: aparecem).
3. A listagem do portal deve ser unificada ou por categoria? (hoje: unificada).
4. A busca deve ser por título, texto, autor e número de protocolo?
5. O requerente deve ser obrigatório a partir de quando?
6. Como numerar/protocolar requerimentos?
