# Atas e Pontos de Frequência — Migração para Laravel Moderno

> Alvo: PHP 8.x + Laravel 11/12. Referência: comportamento observável da `v6`
> (módulo **novo na v6**, inexistente na v5).

---

## 1. Decisão de equivalência funcional

**O que não pode mudar:**

| Item | Regra |
|---|---|
| URLs públicas | `/atas-e-pontos`, `/atas-e-pontos/{slug}`, `/atas-e-pontos/{slug1}/{slug2}` e os nomes `atas-e-pontos.index`, `.show`, `.view` devem ser preservados (links externos e indexadores já publicados). |
| Regra de conteúdo futuro | Documento com data de publicação futura não aparece nem é acessível. |
| Contador de cliques | Deve continuar sendo incrementado na abertura do documento. |
| Comportamento do download | Arquivo anexado continua acessível ao público; em Laravel moderno **deve passar por rota autorizada** (melhoria, não regressão). |
| Estrutura de categorias | Raiz `atas-e-pontos` com filhas; o operador continua podendo criar filhas. |
| Nome exibido | "Atas e Pontos de Frequência". |

**O que pode (e deve) mudar:** segurança das consultas, ausência de SQL em
views, deduplicação dos temas, título correto no CMS, e — se o negócio
aprovar — a introdução de **dados estruturados de presença**.

## 2. Schema de migração Laravel

### 2.1 Tabelas propostas

O modelo mínimo, documental, reproduz a v6 com nomes próprios:

```php
// database/migrations/2026_01_01_000001_create_legislative_documents_table.php
Schema::create('legislative_documents', function (Blueprint $table) {
    $table->id();
    $table->foreignId('category_id')->constrained('content_categories')->cascadeOnDelete();
    $table->string('title');
    $table->longText('body')->nullable();
    $table->string('slug')->unique();
    $table->enum('document_type', ['ata', 'ponto_frequencia', 'outro'])->index();
    $table->foreignId('session_id')->nullable()->constrained('sessions')->nullOnDelete();
    $table->timestamp('published_at')->nullable()->index();
    $table->unsignedBigInteger('file_id')->nullable()->constrained('files')->nullOnDelete();
    $table->unsignedBigInteger('clicks')->default(0);
    $table->boolean('status')->default(true);
    $table->timestamps();
    $table->softDeletes();
});
```

Observações:

- `document_type` substitui a dedução pela categoria filha; a categoria passa
  a ser apenas organização editorial, não semântica.
- `session_id` é **novo**: liga a ata à sessão. Na v6 esse vínculo não
  existe.
- `file_id` é o anexo principal (o equivalente a `archives.view_pdf = true`);
  os demais anexos ficam em uma tabela de relationamento.

### 2.2 Anexos

```php
Schema::create('document_files', function (Blueprint $table) {
    $table->id();
    $table->foreignId('document_id')->constrained('legislative_documents')->cascadeOnDelete();
    $table->foreignId('file_id')->constrained('files')->cascadeOnDelete();
    $table->enum('role', ['principal', 'anexo', 'destaque'])->default('anexo');
    $table->unsignedSmallInteger('order')->default(0);
    $table->timestamps();
});
```

### 2.3 Presença estruturada (opcional, **não existe hoje**)

Se o negócio decidir que a frequência deve ser consultável (quórum, maioria,
absenteísmo), criar:

```php
// database/migrations/2026_01_01_000003_create_session_attendances_table.php
Schema::create('session_attendances', function (Blueprint $table) {
    $table->id();
    $table->foreignId('session_id')->constrained('sessions')->cascadeOnDelete();
    $table->foreignId('councilman_id')->constrained('councilmen')->cascadeOnDelete();
    $table->enum('status', ['presente', 'ausente', 'justificado', 'licenca'])->default('ausente');
    $table->date('session_date');
    $table->timestamps();

    $table->unique(['session_id', 'councilman_id']);
});
```

**Decisão de negócio obrigatória:** a v6 só publica o *documento* de
frequência. Introduzir `session_attendances` implica um fluxo de digitação de
presença (ou OCR do PDF) que **não existe** na v6. Se a v7 mantiver apenas o
documento, o modelo `Ata`/`PontoFrequencia` continua sendo documental e a
lacuna permanece — deve ser registrado formalmente.

## 3. Modelos

```php
// app/Models/LegislativeDocument.php  (exemplo ilustrativo)
class LegislativeDocument extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'category_id', 'title', 'body', 'slug', 'document_type',
        'session_id', 'published_at', 'file_id', 'status',
    ];

    protected $casts = [
        'document_type' => DocumentType::class,
        'published_at' => 'datetime',
        'status' => 'boolean',
    ];

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

    public function session(): BelongsTo
    {
        return $this->belongsTo(Session::class);
    }

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

    public function scopePublished($query)
    {
        return $query->where('published_at', '<=', now());
    }
}
```

- **Enum** `DocumentType` (`ata`, `ponto_frequencia`) substitui o uso de
  strings soltas.
- **Casts** de data e booleanos evitam datas em string no controller.
- **Scopes** (`published`, `ofType`) eliminam os `when()` condicionais do
  controller da v6.

## 4. Form Requests

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

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:150'],
            'body' => ['nullable', 'string'],
            'category_id' => ['required', 'integer', 'exists:content_categories,id'],
            'document_type' => ['required', Rule::enum(DocumentType::class)],
            'session_id' => ['nullable', 'integer', 'exists:sessions,id'],
            'published_at' => ['required', 'date'],
            'files' => ['nullable', 'array', 'max:10'],
            'files.*' => ['file', 'mimes:pdf,doc,docx,odt,xls,xlsx,ppt,pptx,zip,rar', 'max:20480'],
        ];
    }
}
```

Ponto essencial: **todo filtro de usuário entra com binding**, nunca por
concatenação.

## 5. Policies

```php
// app/Policies/LegislativeDocumentPolicy.php  (exemplo ilustrativo)
class LegislativeDocumentPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->hasAccess('atas-e-pontos.view');
    }

    public function create(User $user): bool
    {
        return $user->hasAccess('atas-e-pontos.create');
    }

    public function update(User $user, LegislativeDocument $document): bool
    {
        return $user->hasAccess('atas-e-pontos.edit');
    }

    public function delete(User $user, LegislativeDocument $document): bool
    {
        return $user->hasAccess('atas-e-pontos.delete');
    }
}
```

Manter o padrão `<módulo>.<ação>` já usado pelo `has_access()` da v6 evita
retreinar a operação.

## 6. Endpoints REST sugeridos

```php
// routes/web.php (público)
Route::get('/atas-e-pontos', [LegislativeDocumentController::class, 'index'])
    ->name('atas-e-pontos.index');
Route::get('/atas-e-pontos/{category:slug}', [LegislativeDocumentController::class, 'category'])
    ->name('atas-e-pontos.show');
Route::get('/atas-e-pontos/{category:slug}/{document:slug}', [LegislativeDocumentController::class, 'show'])
    ->name('atas-e-pontos.view');
Route::get('/atas-e-pontos/{document:slug}/arquivo/{file}', [LegislativeDocumentController::class, 'download'])
    ->name('atas-e-pontos.download');

// routes/web.php (CMS)
Route::resource('cms/atas-e-pontos', LegislativeDocumentAdminController::class);
```

```php
// routes/api.php
Route::apiResource('atas-e-pontos', LegislativeDocumentApiController::class)
    ->parameters(['atas-e-pontos' => 'document']);
```

O controller público deve continuar aceitando a mesma query string
(`?ano=`, `?mes=`, `?texto=`, `?page=`) para não quebrar links e behaviors
existentes, mas agora com filtros tipados:

```php
// app/Http/Controllers/Portal/LegislativeDocumentController.php  (exemplo ilustrativo)
public function category(Request $request, ContentCategory $category)
{
    $validated = $request->validate([
        'ano' => ['nullable', 'integer', 'min:1900', 'max:2100'],
        'mes' => ['nullable', 'integer', 'min:1', 'max:12'],
        'texto' => ['nullable', 'string', 'max:120'],
        'page' => ['nullable', 'integer', 'min:1'],
    ]);

    $documents = $category->documents()
        ->published()
        ->when($validated['ano'] ?? null, fn ($q, $ano) => $q->whereYear('published_at', $ano))
        ->when($validated['mes'] ?? null, fn ($q, $mes) => $q->whereMonth('published_at', $mes))
        ->when($validated['texto'] ?? null, fn ($q, $texto) => $q->where(fn ($q) =>
            $q->whereRaw("translate(lower(title), 'àáâãäéèëêíìïîóòõöôúùüûçÇ', 'aaaaaeeeeiiiiooooouuuucc') ilike ?", ['%'.$texto.'%'])
              ->orWhereRaw("translate(lower(body), 'àáâãäéèëêíìïîóòõöôúùüûçÇ', 'aaaaaeeeeiiiiooooouuuucc') ilike ?", ['%'.$texto.'%'])
        ))
        ->orderByDesc('published_at')
        ->orderByDesc('id')
        ->paginate(12)
        ->withQueryString();
}
```

Note-se o uso de **bindings** (`?`) no `whereRaw` e de `withQueryString()` para
preservar os filtros na paginação.

## 7. API Resources

```php
// app/Http/Resources/LegislativeDocumentResource.php  (exemplo ilustrativo)
class LegislativeDocumentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'type' => $this->document_type->value,
            'category' => [
                'name' => $this->category->name,
                'slug' => $this->category->slug,
            ],
            'published_at' => $this->published_at?->toIso8601String(),
            'clicks' => $this->clicks,
            'files' => DocumentFileResource::collection($this->files),
            'url' => route('atas-e-pontos.view', [$this->category->slug, $this->slug]),
        ];
    }
}
```

Para o consumidor interno (menu do portal), um endpoint leve de listagem com
`published_at`, `title` e `category_slug` dispensa o corpo completo.

## 8. Views Blade modernas

| Legado (`v6`) | Moderno (`v7`) |
|---|---|
| 6 cópias em `portal/layouts/modeloN/atas-e-pontos/` | Um componente `<x-legislative.list>`, `<x-legislative.filters>`, `<x-legislative.nav>` incluído pelos temas. |
| JS de Ajax inline em cada `show.blade.php` | Alpine.js ou Stimulus em um único arquivo `resources/js/legislative/filters.js`. |
| `str_limit(strip_tags(stripslashes(...)), 200)` | `Str::limit(Str::stripTags($document->body), 200)` em um accessor `summary`. |
| `files-list.blade.php` com PHP inline | Componente `<x-document-files :document="$document" />` com Blade Components e `@props`. |
| DataTable Yajra com view SQL | `yajra/laravel-datatables` sobre query builder/Eloquent, sem `CREATE VIEW`. |

## 9. Fila, cache e storage

- **Fila:** usar o envio de e-mail de notificação e o processamento de
  documentos; mover o *thumbnailing* de imagens para job.
- **Cache:** cachear a listagem de anos e meses (chave por categoria, TTL
  curto) e invalidar ao publicar; no legado cada visita dispara 2-3 queries
  agregadas.
- **Storage:** anexos em disco `s3` (ou `local` em ambiente restrito), com URL
  temporária; o download passa por controller que valida a sessão e o módulo.
- **PDF:** manter o `view_pdf` como "arquivo principal exibido em iframe" e
  gerar, se desejado, um *preview* com `barryvdh/laravel-dompdf` ou
  `spatie/pdf-to-image`.

## 10. Migração dos dados existentes (v6 → v7)

```php
// app/Console/Commands/ImportLegislativeDocuments.php  (exemplo ilustrativo)
DB::table('contents')
    ->join('category_contents', 'category_contents.id', '=', 'contents.category_contents_id')
    ->whereIn('category_contents.slug', ['atas', 'pontos-de-frequencia'])
    ->whereNull('contents.deleted_at')
    ->orderBy('contents.id')
    ->chunk(200, function ($rows) {
        foreach ($rows as $row) {
            LegislativeDocument::updateOrCreate(
                ['slug' => $row->slug],
                [
                    'title' => $row->title,
                    'body' => $row->text,
                    'category_id' => $row->category_contents_id,
                    'document_type' => $row->slug === 'pontos-de-frequencia'
                        ? DocumentType::PontoFrequencia
                        : DocumentType::Ata,
                    'published_at' => $row->publication_date,
                    'clicks' => (int) $row->clicks,
                ]
            );
        }
    });
```

Checklist de migração:

1. Mapear `contents` das categorias filhas → `legislative_documents`.
2. Migrar `archives` (por `contents_id`) → `document_files` +
   `files`, preservando `view_pdf` (papel `principal`) e `highlight`
   (papel `destaque`).
3. Manter `slug` para preservar URLs; colisão de slug entre módulos deve ser
   resolvida com prefixo por módulo.
4. Preservar `clicks` e `publication_date`.
5. Reindexar a busca (tsvector ou índice de busca externo) se o volume for
   grande.

## 11. Riscos

| Risco | Impacto | Mitigação |
|---|---|---|
| Slug duplicado entre módulos | URL ambígua | Unicidade composta `(módulo, slug)` ou prefixo. |
| Migração de `view_pdf` | Documento perde o display embutido | Teste de paridade por documento. |
| Performance da busca com `translate` | Consulta sem índice | `unaccent`/índice trigram ou busca full-text. |
| Escassez de dados de presença | Continua sem quórum | Decisão formal do negócio (item 2.3). |
| Migração de arquivos em disco | Links quebrados | Regenerar URLs e testar download de cada anexo. |
