# ENQUETES — Migração para Laravel Moderno (v7)

> **Alvo:** PHP 8.3 + Laravel 11/12 + PostgreSQL 15
> Este documento é um plano **concreto** de reconstrução do módulo Enquetes.

---

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

| # | Regra a preservar | Motivo |
|---|---|---|
| E1 | Uma enquete = uma pergunta de escolha única com N opções. | É o contrato com o cidadão; mudar para múltipla escolha muda o produto. |
| E2 | O portal só lista enquetes com `status = true` e `publication_date <= agora`. | Evita publicar enquetes antes da data. |
| E3 | Ao vencer `final_date`, a tela de votação passa a exibir o resultado. | Comportamento central do módulo. |
| E4 | O resultado é o **percentual** de votos por opção, com 2 casas decimais. | É o que o cidadão lê. |
| E5 | A tela de resultado mostra as opções **na ordem de cadastro** (`survey_answers.id ASC`). | A ordem faz parte da comunicação com o cidadão; manter. |
| E6 | A sidebar do portal continua mostrando a enquete ativa mais recente. | Presença da enquete na home é requisito de comunicação. |
| E7 | O voto é anônimo; o IP e o user-agent continuam sendo gravados para rastreabilidade. | Base legal (LGPD) do tratamento de IP. |
| E8 | O CMS lista enquetes **incluindo rascunhos**. | O operador precisa ver o que ainda não publicou. |
| E9 | A URL pública é `/enquetes/{slug}` e o slug é derivado do texto da pergunta. | Links publicados (impressos, redes sociais) não podem quebrar. |

## 2. Schema de migração Laravel

```php
// (ilustrativo) database/migrations/2026_xx_xx_create_surveys_table.php
Schema::create('surveys', function (Blueprint $table) {
    $table->id();
    $table->text('question');
    $table->timestampTz('publication_date')->nullable();
    $table->timestampTz('final_date');
    $table->boolean('status')->default(false);
    $table->string('slug')->unique();
    $table->timestampsTz();
    $table->softDeletesTz();
    $table->index(['status', 'publication_date']);
    $table->index('final_date');
});

Schema::create('survey_answers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('survey_id')->constrained('surveys')->cascadeOnDelete();
    $table->string('answer', 300);
    $table->timestampsTz();
    $table->softDeletesTz();
    $table->index('survey_id');
});

Schema::create('survey_results', function (Blueprint $table) {
    $table->id();
    $table->foreignId('survey_id')->constrained('surveys')->cascadeOnDelete();
    $table->foreignId('survey_answer_id')->constrained('survey_answers')->cascadeOnDelete();
    $table->ipAddress('ip')->nullable();
    $table->string('browser', 500)->nullable();
    $table->timestampsTz();
    $table->index(['survey_id', 'survey_answer_id']);
});
```

### 2.1 Decisões sobre o legado

| Mudança | Motivo |
|---|---|
| `slug` com índice **único** | O legado permitia slugs duplicados (o contador `-{n}` evita colisão apenas por prefixo). |
| `status` com `default(false)` | Elimina o `NULL` atual, que fazia `status = '1'` retornar falso para enquetes nunca publicadas. |
| `timestampTz` | O legado usava `timestamp without time zone` e convertia para `with time zone` na view, gerando deslocamento silencioso. |
| Índices em `(status, publication_date)` e `final_date` | As duas queries mais frequentes do portal são exatamente essas. |
| `cascadeOnDelete` nas FKs | O legado fazia soft delete manual e deixava órfãos; com `cascade` + `forceDelete` explícito o operador tem um caminho de limpeza. |
| **Chave única parcial** em `survey_results` | Ver §4. |

### 2.2 View SQL

A view `enquetes` deve ser **removida**. A listagem do CMS passa a ser uma
query Eloquent com agregação:

```php
Survey::query()
    ->withCount('results')                       // qtd_respostas
    ->orderByDesc('publication_date')
    ->paginate(25);
```

Se a view for mantida por compatibilidade, precisa ser reescrita sem
`::timestamp with time zone` e com `to_char(s.publication_date, 'DD/MM/YYYY')`.

## 3. Modelos

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

    protected $fillable = ['question', 'final_date', 'publication_date', 'status', 'slug'];

    protected function casts(): array
    {
        return [
            'status'           => 'boolean',
            'publication_date' => 'datetime',
            'final_date'       => 'datetime',
        ];
    }

    public function answers(): HasMany          // opções
    { return $this->hasMany(SurveyAnswer::class)->orderBy('id'); }

    public function results(): HasMany          // votos
    { return $this->hasMany(SurveyResult::class); }

    public function isOpen(): bool
    { return $this->status && $this->publication_date?->isPast() && $this->final_date->isFuture(); }
}
```

| Model | Mudanças |
|---|---|
| `Survey` | `casts` explícitos; relação `answers()` **renomeada** (a do legado `answers()` retornava votos); helper `isOpen()` centraliza a regra de janela de votação; `scopePublished()` e `scopeActive()`. |
| `SurveyAnswer` | `casts` mínimo; relação `survey()` como `belongsTo` (o legado tinha `hasMany` invertido). |
| `SurveyResult` | `casts` de `ip`; relação `survey()` e `answer()` como `belongsTo`; remover `surveyAnswers()` (quebrada). |
| Remover | `QuestionType`, `QuestionCategory`, `Questions`, `Options`, `OptionCategory`, `OptionCategoryHasOption` **não fazem parte deste módulo** — ver `02-MODELAGEM.md` §5.1. Se o módulo *Revisões* também for migrado, eles seguem para lá. |

## 4. Integridade do voto (o principal ganho funcional)

O legado permitia votos duplicados, votos em enquete encerrada e votos
cruzados entre enquetes. Na v7:

```php
// (ilustrativo) app/Http/Requests/StoreSurveyVoteRequest.php
public function rules(): array
{
    return [
        'survey_id'        => ['required', 'integer', 'exists:surveys,id'],
        'survey_answer_id' => ['required', 'integer', Rule::exists('survey_answers', 'id')
            ->where('survey_id', $this->input('survey_id'))],
    ];
}
```

E, no controller do portal:

```php
// (ilustrativo) app/Http/Controllers/Portal/SurveyVoteController.php
$survey = Survey::where('slug', $slug)->firstOrFail();

abort_unless($survey->isOpen(), 422, 'Enquete encerrada.');

$exists = SurveyResult::where('survey_id', $survey->id)
    ->where('ip', $request->ip())
    ->exists();

abort_if($exists, 422, 'Você já votou nesta enquete.');
```

Regras de equivalência a observar:

- A checagem "já votou" passa a consultar **todos** os votos da enquete do IP
  do visitante (o legado olhava apenas o primeiro registro) — corrige A7.
- A recusa de voto duplicado **altera o comportamento observável** (o legado
  aceitaria). Decisão de negócio necessária: se a prefeitura quiser
  permitir múltiplos votos (por exemplo, um voto por dispositivo), usar
  fingerprint (cookie + user-agent) em vez de IP puro, porque IP é compartilhado
  em redes públicas.
- A recusa de voto em enquete encerrada **altera o comportamento observável**
  (o legado aceitava). Alinhado a E3 e a A4.

## 5. Form Requests

| Request | Regras |
|---|---|
| `StoreSurveyRequest` (CMS) | `question` required string max 65535; `publication_date` required date; `final_date` required date `after:publication_date`; `answers` required array min 2; `answers.*.answer` required string max 300; `status` boolean |
| `UpdateSurveyRequest` | idem + regra de bloqueio: `Rule::prohibited()` quando a enquete já tiver votos (o `$disabled` legado) |
| `StoreSurveyVoteRequest` (Portal) | `survey_id` required exists; `survey_answer_id` required exists dentro da enquete |

Mensagens em português, no padrão do Laravel (`validation.php` do projeto).

## 6. Policies e autorização

```php
// (ilustrativo) app/Policies/SurveyPolicy.php
final class SurveyPolicy
{
    public function viewAny(User $user): bool   { return $user->can('enquete.index'); }
    public function create(User $user): bool    { return $user->can('enquete.create'); }
    public function update(User $user, Survey $s): bool
    { return $user->can('enquete.edit') && $s->results()->doesntExist(); }
    public function delete(User $user, Survey $s): bool { return $user->can('enquete.delete'); }
}
```

Registrar em `AuthServiceProvider`/`Gate::policy`. Isso substitui:
- o `has_access()` em view (`v6/app/Library/Helper/appHelpers.php:22`);
- o `check.route.permission` (que, no módulo Enquetes, **nunca foi aplicado**);
- o `check.logged` isolado.

## 7. Endpoints REST/JSON

### 7.1 CMS (prefixo `/cms` ou `/admin`)

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/enquetes` | Listagem paginada (substitui o DataTable sobre a view) |
| POST | `/api/enquetes` | Cria enquete + opções em transação |
| GET | `/api/enquetes/{survey}` | Detalhe + contagem de votos |
| PUT/PATCH | `/api/enquetes/{survey}` | Atualiza (bloqueado se já houver votos) |
| DELETE | `/api/enquetes/{survey}` | Exclui (soft) + opções |
| POST | `/api/enquetes/bulk-delete` | Exclusão em lote (`{ids: [...]}`) |
| GET | `/api/enquetes/{survey}/results` | Apuração agregada por opção |

### 7.2 Portal (público)

| Método | URI | Descrição |
|---|---|---|
| GET | `/enquetes` | Lista de enquetes (HTML) |
| GET | `/enquetes/{slug}` | Votação (HTML) |
| GET | `/enquetes/encerrada/{slug}` | Resultado (HTML) |
| POST | `/api/enquetes/{slug}/votes` | Registra voto → `201 {message}` / `422 {message}` |

O endpoint de voto deve responder **objeto JSON** (`{"message": "..."}`), e não
o array posicional `[201, "..."]` do legado.

## 8. Recursos de API

```php
// (ilustrativo) app/Http/Resources/SurveyResource.php
final class SurveyResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'question'   => $this->question,
            'slug'       => $this->slug,
            'status'     => (bool) $this->status,
            'published_at' => $this->publication_date?->toIso8601String(),
            'final_date' => $this->final_date->toIso8601String(),
            'answers'    => SurveyAnswerResource::collection($this->answers),
            'votes_count'=> $this->whenCounted('results'),
        ];
    }
}
```

## 9. Views Blade modernas

| Legado | v7 |
|---|---|
| `portal.layouts.surveys.index/show/closed` + 6 sidebars duplicadas | Um componente `<x-surveys.list>`, `<x-surveys.vote-card>`, `<x-surveys.result-card>` + um `<x-surveys.sidebar-widget>` incluído uma única vez no layout do portal |
| `@inject('surveyModel', ...)` + `@php` com query dentro do Blade | Controller da página entrega `$surveySidebar` pronto; a view não consulta o banco |
| `Form::open(['route' => 'contato.enviar'])` | `<form method="post" action="{{ route('enquetes.votes.store', $survey) }}">` com `@csrf` |
| `function contact()` global em 7 arquivos | Componente Alpine.js / botão com `x-on:click` e `x-data` local |
| Filtro por texto em JS puro | Alpine.js `x-model` + `x-show` |
| Barras de progresso com `aria-valuenow="40"` fixo | `<div role="progressbar" :aria-valuenow="{{ $pct }}">` com o valor real |
| "Enquete encerrada em {publication_date}" | `final_date` |

Componente de apuração (reutilizável entre a sidebar e a tela de resultado):

```blade
{{-- (ilustrativo) resources/views/components/surveys/result.blade.php --}}
@props(['survey'])
<ul class="list-unstyled">
    @foreach ($survey->answers as $answer)
        @php $pct = $survey->resultsCount() > 0
            ? round($answer->votes_count * 100 / $survey->resultsCount(), 2)
            : 0; @endphp
        <li class="mb-3">
            <span>{{ $answer->answer }} — <strong>{{ number_format($pct, 2, '.', '') }} %</strong></span>
            <div class="progress"><div class="progress-bar" role="progressbar"
                 aria-valuenow="{{ $pct }}" aria-valuemin="0" aria-valuemax="100"
                 style="width: {{ $pct }}%"></div></div>
        </li>
    @endforeach
</ul>
```

## 10. Apuração: eliminar o N+1

O legado fazia 1+N queries e carregava todos os votos em memória
(`v6/app/Http/Controllers/Portal/SurveyController.php:68-79`). Na v7:

```php
// (ilustrativo) app/Services/SurveyTallyService.php
final class SurveyTallyService
{
    public function tally(Survey $survey): array
    {
        $counts = $survey->results()
            ->selectRaw('survey_answer_id, count(*) as total')
            ->groupBy('survey_answer_id')
            ->pluck('total', 'survey_answer_id');

        $total = $counts->sum();

        return $survey->answers->map(fn (SurveyAnswer $a) => [
            'answer'     => $a->answer,
            'votes'      => (int) ($counts[$a->id] ?? 0),
            'percentage' => $total > 0
                ? number_format((($counts[$a->id] ?? 0) * 100) / $total, 2, '.', '')
                : '0.00',
        ])->all();
    }
}
```

Para enquetes encerradas, o resultado pode ser **materializado** em uma tabela
`survey_tallies` (ou cacheado por `Cache::remember`), pois não muda mais.

## 11. Fila, cache e storage

| Recurso | Uso na v7 |
|---|---|
| Fila | Nenhuma obrigatória. Opcional: processar `survey_results` em background para gerar a apuração materializada e notificar o fim da enquete. |
| Cache | `Cache::remember("survey:tally:{$survey->id}", 3600, ...)` para o resultado de enquetes encerradas; invalidar ao receber voto. O legado já usa `Cache::rememberForever('modulos')` para o flag de módulo — manter esse padrão. |
| Storage | Nenhum arquivo do módulo. |
| Rate limiting | `RateLimiter::for('survey-vote', ...)` por IP — mitiga abuso de votação em massa (não existia no legado). |

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

| Risco | Decisão |
|---|---|
| Bloquear voto duplicado muda o comportamento observável | **Exige aval do negócio.** Alternativa menos invasiva: manter o voto aceito, mas contabilizar **um voto por IP por enquete** na apuração (deduplicar na leitura) |
| Bloquear voto após o encerramento muda o comportamento observável | Alinhado a E3; recomenda-se aprovar |
| Slug único pode colidir com slugs já publicados em URLs impressas | Criar a migration com índice único **após** resolver duplicatas existentes; manter a resolução de colisão atual (`-{n}`) |
| Migração de `timestamp without time zone` para `timestampTz` altera os valores se o servidor estava em UTC e o banco em outro fuso | Converter com `AT TIME ZONE 'America/Sao_Paulo'` explicitamente e validar com uma amostra de enquiries antes do cutover |
| `survey_results` não tem soft delete; a migração para `cascadeOnDelete` só vale com `forceDelete` | Manter soft delete em `surveys` e oferecer "excluir definitivamente" como operação separada e auditada |
| Apuração atual é feita sobre `survey_answers` **atuais**; opções recriadas na edição quebram o histórico | Na v7, **impedir a edição** quando houver votos (Policy §6) e, para enquetes já editadas no legado, o histórico é irrecuperável — documentar |
| Migração do flag `modulos.enquetes` | Manter a tabela `modulos` (ou renomear para `features` com deprecation) e o cache `rememberForever` |
| Migração de `has_access()` para Policies exige mapear `enquete.*` para um sistema de papéis | Mapear `enquete.index/create/edit/delete` para as permissões equivalentes já cadastradas em `v6/database/seeds/RolesTableSeeder.php:26` |

## 13. Checklist de cutover

1. Criar migrations (tabelas + índices) e a tabela de apuração materializada.
2. Migrar `surveys`, `survey_answers`, `survey_results` com conversão de fuso.
3. Resolver slugs duplicados antes de criar o índice único.
4. Implementar models, Policy, Requests, Service de apuração e Resources.
5. Reimplementar as 3 telas do portal como componentes Blade únicos.
6. Reimplementar a listagem do CMS com paginação server-side (elimina a view SQL).
7. Habilitar o voto com token CSRF, rate limit e chave de unicidade parcial.
8. Validar a paridade numérica dos percentuais com uma amostra de enquetes do
   banco legado (fonte: `survey_results`).
9. Migrar as flags de `modulos` / `menu_orders` / `resources`.
10. Desativar as rotas mortas (`ajax.delete.answer`, `enquete/{slug}/{id}/delete`)
    no cutover — ver `08-GAPS-E-DIVIDAS.md`.
