# Comitê de Investimentos — Migração para Laravel Moderno (v7)

## 1. Decisões de equivalência funcional (o que NÃO pode mudar)

| # | Comportamento legado | Regra para a v7 |
|---|---|---|
| E1 | `/comite-de-investimentos` redireciona para a subcategoria de menor `order` | Manter o redirecionamento (ou trocar por uma listagem única, desde que o cidadão encontre as atas no mesmo número de cliques) |
| E2 | Subcategorias ordenadas por `order ASC` | Preservar a ordenação manual |
| E3 | Paginação de 12 itens | Preservar ou tornar configurável |
| E4 | Filtro de texto case-insensitive e sem acento, sobre título **e** corpo | Preservar; o usuário espera encontrar "Comite" ao digitar "comite" |
| E5 | Filtro de mês sem o de ano esconde atas futuras | Preservar a **intenção** (não revelar conteúdo não publicado) |
| E6 | Filtros por query string (`ano`, `mes`, `texto`) | Manter os mesmos nomes de parâmetro para não quebrar links salvos |
| E7 | Incremento de `clicks` a cada leitura | Preservar (indicador de acesso) |
| E8 | Anexos: `highlight = null` são os "anexos comuns"; `view_pdf = true` é o PDF embutido | Preservar a semântica das flags |
| E9 | Atas futuras ficam invisíveis no portal | Preservar |
| E10 | Exclusão em lote e individual pelos mesmos endpoints do CMS | Preservar o comportamento observável (a URL pode mudar) |

## 2. Schema de migração Laravel (equivalente)

O módulo não cria tabela nova na v7. O schema equivalente é o de `contents` +
`category_contents` + `archives`. Para isolar o comitê, **recomenda-se** um
modelo dedicado com tabela própria (decisão de projeto a validar com o negócio):

```php
// (ilustrativo) migration para tabela própria do comitê
Schema::create('comite_atas', function (Blueprint $table) {
    $table->id();
    $table->string('title', 191);
    $table->longText('text')->nullable();
    $table->string('slug', 191)->unique();
    $table->foreignId('comite_categoria_id')->constrained('comite_categorias');
    $table->timestamp('publication_date')->nullable();
    $table->unsignedInteger('clicks')->default(0);
    $table->timestamps();
    $table->softDeletes();
    $table->index(['comite_categoria_id', 'publication_date']);
});
```

Alternativa **mínima** (mantendo `contents`): apenas garantir índices.

```php
// (ilustrativo) índices sobre contents
Schema::table('contents', function (Blueprint $table) {
    $table->index(['category_contents_id', 'publication_date']);
});
```

### 2.1 View SQL — substituta recomendada

Eliminar a view `comite_de_investimentos` e servir a listagem do CMS por Eloquent:

```php
// (ilustrativo)
$atas = CommitteeMinute::query()
    ->whereHas('category', fn ($q) => $q->where('slug', 'comite-de-investimentos'))
    ->whereNull('deleted_at')
    ->orderByDesc('publication_date')
    ->paginate(12);
```

Se a view for mantida (por compatibilidade com relatórios), reescrever como
migration de view com **colunas explícitas e sem `row_number` incoerente**:

```sql
-- (ilustrativo)
CREATE OR REPLACE VIEW comite_de_investimentos AS
SELECT c.id,
       c.title AS titulo,
       to_char(c.publication_date, 'DD/MM/YYYY') AS data_publicacao,
       cc.name  AS categoria,
       cc.slug  AS categoria_slug,
       c.slug   AS slug
FROM contents c
JOIN category_contents cc ON cc.id = c.category_contents_id
WHERE c.deleted_at IS NULL
  AND cc.category_contents_id = (
        SELECT id FROM category_contents WHERE slug = 'comite-de-investimentos'
      );
```

## 3. Modelos

### 3.1 `ComiteCategory`

```php
// (ilustrativo)
class ComiteCategory extends Model
{
    use SoftDeletes, HasFactory;

    protected $fillable = ['name', 'slug', 'order'];

    protected function casts(): array
    {
        return ['order' => 'integer'];
    }

    public function parent(): BelongsTo
    {
        return $this->belongsTo(self::class, 'category_contents_id');
    }

    public function minutes(): HasMany
    {
        return $this->hasMany(ComiteMinute::class, 'comite_categoria_id');
    }
}
```

### 3.2 `ComiteMinute`

| Atributo | Tipo | Observação |
|---|---|---|
| `title` | string(191) | required |
| `text` | longText | HTML do editor |
| `slug` | string(191) | unique, gerado do título |
| `publication_date` | datetime | required (corrige a lacuna da v6) |
| `clicks` | unsigned int | default 0 |
| `comite_categoria_id` | FK | required |

Casts sugeridos: `publication_date` → `datetime`; `clicks` → `integer`.

### 3.3 Enums

Não há enum natural no legado. Se a v7 criar, candidatos:

| Enum | Valores | Origem |
|---|---|---|
| `PublicationStatus` | `draft`, `published`, `scheduled` | Derivado de `status` + `publication_date` |
| `ArchiveKind` | `anexo`, `destaque`, `pdf_embutido` | Derivado de `highlight` / `view_pdf` |

## 4. Form Requests

```php
// (ilustrativo)
class StoreComiteMinuteRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', ComiteMinute::class);
    }

    public function rules(): array
    {
        return [
            'title'             => ['required', 'string', 'max:150'],
            'text'              => ['required', 'string'],
            'publication_date'  => ['required', 'date'],
            'comite_categoria_id' => ['required', 'integer', 'exists:comite_categorias,id'],
            'slug'              => ['nullable', 'string', 'max:200', 'unique:comite_atas,slug'],
            'archives.*'        => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx'],
        ];
    }
}
```

```php
// (ilustrativo)
class FilterComiteMinuteRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'ano'   => ['nullable', 'integer', 'between:2000,2100'],
            'mes'   => ['nullable', 'integer', 'between:1,12'],
            'texto' => ['nullable', 'string', 'max:120'],
        ];
    }
}
```

O `FilterComiteMinuteRequest` elimina de raiz o vetor de SQL injection: o ano
passa a ser `integer` e entra como *binding*.

## 5. Policies / autorização

Substituir o middleware textual `check.route.permission` por Policies:

```php
// (ilustrativo)
class ComiteMinutePolicy
{
    public function viewAny(User $user): bool   { return $user->hasPermissionTo('comite-de-investimentos.index'); }
    public function create(User $user): bool    { return $user->hasPermissionTo('comite-de-investimentos.create'); }
    public function update(User $user, ComiteMinute $m): bool { return $user->hasPermissionTo('comite-de-investimentos.edit'); }
    public function delete(User $user, ComiteMinute $m): bool { return $user->hasPermissionTo('comite-de-investimentos.delete'); }
}
```

`super-admin` continua contemplado via `Gate::before()`.

## 6. Endpoints REST/JSON sugeridos

### Portal (público)

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/comite-de-investimentos/categorias` | Subcategorias ordenadas |
| GET | `/api/comite-de-investimentos/atas` | Listagem paginada, filtros `ano`, `mes`, `texto` |
| GET | `/api/comite-de-investimentos/atas/{slug}` | Detalhe (incrementa `clicks`) |

### CMS

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/cms/comite-de-investimentos/atas` | DataTable (paginação + ordenação server-side) |
| POST | `/api/cms/comite-de-investimentos/atas` | Criar |
| PUT | `/api/cms/comite-de-investimentos/atas/{id}` | Atualizar |
| DELETE | `/api/cms/comite-de-investimentos/atas/{id}` | Excluir (lógico) |
| POST | `/api/cms/comite-de-investimentos/atas/destroy-all` | Exclusão em lote |
| GET/POST/PUT/DELETE | `/api/cms/comite-de-investimentos/categorias` | CRUD de categorias |

## 7. Recursos de API

```php
// (ilustrativo)
class ComiteMinuteResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'                => $this->id,
            'title'             => $this->title,
            'slug'              => $this->slug,
            'text'              => $this->text,
            'publication_date'  => $this->publication_date?->toIso8601String(),
            'categoria'         => $this->categoria?->only(['id', 'name', 'slug']),
            'clicks'            => $this->clicks,
            'arquivos'          => ComiteArchiveResource::collection($this->archives),
        ];
    }
}
```

## 8. Views Blade modernas

| Legado | v7 |
|---|---|
| `portal.layouts.{modelo}.comite-de-investimentos.list` | Componente `<x-portal.comite.filters>` + `<x-portal.comite.list>` |
| `portal.layouts.{modelo}.comite-de-investimentos.pub-nav` | `<x-portal.comite.category-nav>` |
| `portal.layouts.{modelo}.comite-de-investimentos.show` | `<x-portal.page>` com `<x-slot name="breadcrumb">` |
| `portal.layouts.{modelo}.comite-de-investimentos.view` | `<x-portal.article>` |
| `prefecture.contents.comite-de-investimentos.*` | `<x-admin.crud>` genérico ou páginas Livewire |
| `prefecture.category-contents.comite-de-investimentos.*` | `<x-admin.category-tree>` |

Componente de filtros (exemplo):

```blade
{{-- (ilustrativo) --}}
<form method="GET" action="{{ route('portal.comite-de-investimentos.show', $category->slug) }}">
    <select name="ano">
        <option value="">Ano</option>
        @foreach ($anos as $ano)
            <option value="{{ $ano }}" @selected((int) $ano === (int) $anoSelecionado)>{{ $ano }}</option>
        @endforeach
    </select>
    <select name="mes"><option value="">Mês</option></select>
    <input type="search" name="texto" value="{{ request('texto') }}">
</form>
```

A paginação AJAX deve ser trocada por **Livewire/Alpine + fetch do endpoint
JSON**, eliminando o partial HTML dentro de `Response::json`.

## 9. Fila, cache e storage

| Aspecto | Legado | v7 |
|---|---|---|
| Cache | não há | cachear `anos`/`meses` por 5 min (`Cache::remember`) e invalidar ao publicar ata |
| Fila | não há | opcional: processar anexos (thumbnail/conversão para PDF) em job |
| Storage | `Storage` do Laravel (usado por `FileTrait`) | `storage/app/public/comite/{id}` com `Storage::disk('public')` |
| Sessão | não há | não necessária |

## 10. Riscos da migração

| Risco | Mitigação |
|---|---|
| Dados de `contents` compartilhados com ~15 módulos | **Não** migrar `contents` globalmente; criar tabela própria só para as atas do comitê, com script de backfill idempotente |
| Subcategorias hoje moram em `category_contents` com auto-FK | Modelar `comite_categorias` com `parent_id` próprio, importando `name`, `slug`, `order` |
| Permissões amarradas a strings (`has_access`) | Criar permissões `comite-de-investimentos.*` e mapear os papéis existentes na migração |
| Slugs de URL mudarem | Preservar `slug` de categorias e de atas na importação |
| A view SQL ser usada por relatórios externos | Manter a view em modo compatibilidade por 1 release, migrando consumidores para a nova query |
| Registro em `modulos` id 48 | Manter o id e o slug para não quebrar o menu e as referências de `menu_orders`/`resources` |
