# Formulários Dinâmicos — Migração Moderna (V7)

> **Slug:** `infraestrutura-formulariosdinamicos` · **Versão analisada:** `lotep`
> **Destino:** Laravel 12 / PHP 8.4, PostgreSQL 18, DDD-lite (`core`/`services`/`layout`/`modules`)
> **Raiz de referência:** `/home/one/projects/maxima/v7/lotep`

---

## 1. Decisão de arquitetura

O V7 **não possui** formulários dinâmicos. A reconstrução deve separar duas responsabilidades:

| Peça | Onde | Papel |
|---|---|---|
| Motor (schema, scoring, faixas, submissão anônima, agregação) | `src/services/Formularios/` | **service headless**, sem rotas públicas e sem conhecer módulos |
| Operação de formulários (CMS + portal) | `src/modules/Infraestrutura/Formularios/` | caso de uso: telas, rotas, permissões, i18n |

Regras do V7 aplicadas desde o início:

- **sem traits, sem classes abstratas, sem generics** (ADR-001/ADR-028);
- **jsonb** para configuração e respostas, **nunca HTML** em coluna (ADR-017/ADR-028);
- **Gates** `{modulo}.{ver|editar|aprovar|arquivar}` (ADR-033);
- **ativação por dado** (ADR-034), auditoria/telemetria nativas (ADR-024/ADR-035);
- **CSP sem inline** (ADR-022) e i18n em `Lang/{locale}` (ADR-012);
- **zero CDN**, JS em módulos Vite (ADR-002/ADR-010).

O motor é service porque é usado por módulos distintos (portal genérico, AutoTeste, futuros:
ouvidoria, pesquisa de satisfação, inscrição em eventos). O portal público só existe porque há um
módulo que o expõe — o service não cria rota pública (ADR-015).

## 2. Schema de migration equivalente

PostgreSQL é o único alvo (ADR-007). Schema dedicado `formularios`, UUIDv7, `timestamptz`, `down()`
que renomeia para `backup_*` (ADR-017).

```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\Log;
use Illuminate\Support\Facades\Schema;

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

    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;');

        DB::beginTransaction();

        try {
            Schema::create('formularios', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));

                $table->text('slug')->unique();
                $table->text('titulo');
                $table->text('descricao')->nullable();
                $table->text('status')->default('rascunho'); // rascunho | ativo | arquivado
                $table->unsignedInteger('versao_atual')->default(1);
                $table->timestampTz('publicado_em')->nullable();
                $table->timestampTz('expira_em')->nullable();
                $table->jsonb('metadados')->nullable();
                $table->timestampsTz();
                $table->softDeletesTz();

                $table->index('status');
                $table->index(['status', 'expira_em']);
            });

            Schema::create('versoes', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));

                $table->uuid('formulario_id');
                $table->unsignedInteger('numero');
                $table->jsonb('scoring_config')->default(new Expression("'{}'::jsonb"));
                $table->timestampTz('publicado_em')->nullable();
                $table->timestampsTz();

                $table->unique(['formulario_id', 'numero']);
                $table->foreign('formulario_id')->references('id')->on('formularios')->cascadeOnDelete();
            });

            Schema::create('campos', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));

                $table->uuid('versao_id');
                $table->text('tipo');
                $table->text('nome');
                $table->text('rotulo');            // chave de tradução ou texto do CMS
                $table->text('ajuda')->nullable();
                $table->boolean('obrigatorio')->default(false);
                $table->unsignedInteger('ordem')->default(0);
                $table->decimal('peso', 10, 3)->default(0);
                $table->jsonb('opcoes')->nullable();   // [{value, rotulo, weight}]
                $table->jsonb('regras')->nullable();   // min/max/pattern/min_items/accept
                $table->timestampsTz();

                $table->unique(['versao_id', 'nome']);
                $table->index(['versao_id', 'ordem']);
                $table->foreign('versao_id')->references('id')->on('versoes')->cascadeOnDelete();
            });

            Schema::create('submissoes', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));

                $table->uuid('formulario_id');
                $table->uuid('versao_id');
                $table->jsonb('respostas');
                $table->decimal('pontuacao', 10, 2)->default(0);
                $table->decimal('pontuacao_maxima', 10, 2)->default(0);
                $table->text('faixa')->nullable();
                $table->jsonb('resultado')->nullable(); // faixa + textos + canais congelados
                $table->text('ip_hash')->nullable();
                $table->text('user_agent')->nullable();
                $table->text('origem')->nullable();
                $table->timestampTz('consentimento_em')->nullable();
                $table->timestampTz('expira_em')->nullable(); // validade do link de resultado
                $table->timestampsTz();
                $table->softDeletesTz();

                $table->index(['formulario_id', 'versao_id']);
                $table->index(['formulario_id', 'created_at']);
                $table->index(['formulario_id', 'faixa']);
                $table->index('expira_em');
                $table->foreign('formulario_id')->references('id')->on('formularios')->cascadeOnDelete();
                $table->foreign('versao_id')->references('id')->on('versoes')->cascadeOnDelete();
            });

            // O token NÃO é coluna: o link é assinado (HMAC) e carrega id + validade.
            // Se for necessário revogar, uma coluna `revogado_em` entra em migration futura.

            DB::statement('CREATE INDEX submissoes_respostas_gin_idx ON submissoes USING gin (respostas jsonb_path_ops)');

            DB::commit();
        } catch (Throwable $e) {
            DB::rollBack();
            Log::emergency('FALHA NA MIGRAÇÃO formularios: '.$e->getMessage());
            throw $e;
        }

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

    public function down(): void
    {
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');

        foreach (['submissoes', 'campos', 'versoes', 'formularios'] as $table) {
            $backup = 'backup_'.$table.'_'.date('Y_m_d_His');
            if (Schema::hasTable($table)) {
                Schema::rename($table, $backup);
            }
        }

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

> Trecho ilustrativo do alvo, não copiado do legado. Diferenças deliberadas em relação ao original:
> `uuidv7()` no banco, nomes em português alinhados ao token `formularios`, `respostas`/`resultado`
> no lugar de `answers`/`result_data`, `expira_em` na submissão e **sem** `result_token` em texto
> claro.

### 2.1 Por que manter as 4 tabelas

O modelo é o ativo real do legado: permite que uma submissão antiga continue interpretável depois
que o formulário muda. O `schema` stub não é recriado — a estrutura mora em `campos` (fonte única).

### 2.2 Índices

- `submissoes_respostas_gin_idx` só se justifica se houver consulta real por conteúdo de resposta
  (ex.: filtrar por `respostas @> '{"q1":"3"}'`). Sem esse caso de uso, **não criar** — o legado
  criou e nunca usou. A decisão deve acompanhar o relatório: se ele passar a filtrar por resposta,
  o GIN entra; se for só agregação por formulário/faixa, os índices b-tree bastam.
- `submissoes.expira_em` dá suporte ao expurgo de links e à retenção.

## 3. Models

Sem traits e sem `protected static function boot()`: IDs via default do banco; casts em
`casts()`; relações tipadas; `final`.

```php
<?php

declare(strict_types=1);

namespace Services\Formularios\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;

/**
 * @property string $id
 * @property string $slug
 * @property string $titulo
 * @property string $status
 * @property int $versao_atual
 * @property Carbon|null $publicado_em
 * @property Carbon|null $expira_em
 * @property array<string, mixed>|null $metadados
 */
final class Formulario extends Model
{
    protected $table = 'formularios.formularios';

    public $incrementing = false;

    protected $keyType = 'string';

    /** @var list<string> */
    protected $fillable = ['slug', 'titulo', 'descricao', 'status', 'versao_atual', 'publicado_em', 'expira_em', 'metadados'];

    protected function casts(): array
    {
        return [
            'versao_atual' => 'integer',
            'publicado_em' => 'datetime',
            'expira_em' => 'datetime',
            'metadados' => 'array',
        ];
    }

    public function versoes(): HasMany
    {
        return $this->hasMany(Versao::class, 'formulario_id');
    }

    public function submissoes(): HasMany
    {
        return $this->hasMany(Submissao::class, 'formulario_id');
    }
}
```

Os demais models seguem o mesmo padrão:

| Model | Tabela | Casts |
|---|---|---|
| `Versao` | `formularios.versoes` | `scoring_config => array`, `numero => integer`, `publicado_em => datetime` |
| `Campo` | `formularios.campos` | `opcoes => array`, `regras => array`, `obrigatorio => boolean`, `peso => float` |
| `Submissao` | `formularios.submissoes` | `respostas => array`, `resultado => array`, `pontuacao => float`, `pontuacao_maxima => float`, `consentimento_em => datetime`, `expira_em => datetime` |

Não usar `SoftDeletes` em versões/campos: editar uma versão só é permitido enquanto não há
submissões; depois, cria-se outra. O soft delete fica só em formulário e submissão.

## 4. Enums sugeridos

```php
enum StatusFormulario: string
{
    case Rascunho = 'rascunho';
    case Ativo = 'ativo';
    case Arquivado = 'arquivado';
}

enum TipoCampo: string
{
    case Texto = 'texto';
    case TextoLongo = 'texto_longo';
    case Escolha = 'escolha';
    case Multipla = 'multipla';
    case Faixa = 'faixa';
    case Satisfacao = 'satisfacao';
    case Arquivo = 'arquivo';
}

enum TipoFaixa: string
{
    case Percentual = 'percentual';
    case Absoluta = 'absoluta';
}
```

`Arquivo` só deve existir se o fluxo de upload for implementado de verdade (Media service,
ADR-026). Caso contrário, não declarar o case — o legado mostra o custo de um tipo órfão.

## 5. Actions e contratos

O V7 prefere ações pequenas e invocáveis a uma fachada grande. Contratos em
`src/services/Formularios/Contracts/`:

| Contrato | Implementação | Papel |
|---|---|---|
| `FormularioAtivo` | `LocalizarFormularioAtivo` | resolve slug → formulação ativa + versão |
| `ValidadorRespostas` | `ValidarRespostas` | valida por tipo/regras, devolve erros por nome |
| `CalculadoraPontuacao` | `CalcularPontuacao` | score/max/detalhe por campo |
| `ClassificadorFaixa` | `ClassificarFaixa` | estrategia percentual/absoluta |
| `RegistroSubmissao` | `RegistrarSubmissao` | consentimento, hash IP, persistência, auditoria |
| `RelatorioFormulario` | `GerarRelatorio` | agregados em SQL + distribuição |
| `ExportadorCsv` | `ExportarCsv` | streaming de submissões |
| `AssinadorResultado` | `AssinarResultado` | URL assinada com validade |

Fluxo de submissão equivalente:

```
RegistrarSubmissao::execute(RespostasDTO, Versao, ContextoDTO): Submissao
  1. ValidadorRespostas::validar → erros
  2. CalculadoraPontuacao::calcular → PontuacaoDTO
  3. ClassificadorFaixa::classificar → ResultadoDTO|null
  4. hashIp(contexto.ip) → ip_hash
  5. Submissao::create(...)
  6. AuditWriter::record(AuditEntry(event: 'formularios.submetido', ...))
  7. AssinadorResultado::url(submissao)
```

`AssinadorResultado` usa `URL::temporarySignedRoute('portal.formularios.resultado', now()->addDays(30), ['submissao' => $id])`
— assinatura nativa do Laravel, com expiração, sem coluna de token. O controller valida com o
middleware `signed`. Isso resolve o comentário enganoso do legado sem inventar criptografia.

## 6. Requests

`SubmeterFormularioRequest` (portal) e `ApiSubmeterFormularioRequest` (API) compartilham as regras
de campos dinâmicos via `withValidator`/regra customizada, porque as regras dependem da versão:

```php
final class SubmeterFormularioRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'respostas' => ['required', 'array'],
            'consentimento' => ['accepted'],
            'website' => ['prohibited'],           // honeypot (ADR-021)
            'aberto_em' => ['required', 'integer'], // time-trap
        ];
    }
}
```

A validação dinâmica roda na `Action`, não no FormRequest: o FormRequest valida o envelope, a ação
valida `respostas` contra `campos` e devolve `ValidationException` com chaves iguais ao nome do
campo. Isso evita duplicar a lista de campos em dois lugares.

`SalvarFormularioRequest` (CMS):

| Campo | Regra |
|---|---|
| `slug` | `required|string|max:120|regex:/^[a-z0-9-]+$/|unique` (escopo próprio) |
| `titulo` | `required|string|max:255` |
| `descricao` | `nullable|string|max:2000` |
| `status` | `required|enum` |
| `expira_em` | `nullable|date|after:now` |
| `campos` | `required|array|min:1` |
| `campos.*.tipo` | `required|enum:TipoCampo` |
| `campos.*.nome` | `required|string|max:80|distinct` |
| `campos.*.rotulo` | `required|string|max:255` |
| `campos.*.opcoes.*.rotulo` | `required|string|max:255|distinct` |
| `campos.*.opcoes.*.peso` | `nullable|numeric|min:0|max:100` |
| `faixas` | `array` |
| `faixas.*.limite` | `required|numeric|min:0|max:100` |
| `faixas.*.titulo` | `required|string|max:120` |

## 7. Permissões e autorização

Módulo `Formularios` declara no `ModuleProvider`:

```php
'permissions' => [Permission::VIEW, Permission::EDIT, Permission::APPROVE, Permission::ARCHIVE],
```

| Rota | Permissão |
|---|---|
| CMS listar/ver relatórios/submissões | `formularios.ver` |
| CMS criar/editar | `formularios.editar` |
| CMS publicar/despublicar (status `ativo`/`arquivado`) | `formularios.aprovar` |
| CMS arquivar/desarquivar | `formularios.arquivar` |
| CMS exportar CSV | `formularios.ver` + auditoria de acesso (ADR-035) |
| Portal | público, sem Gate |
| API schema/resultados | público (dados agregados) |
| API submissão | público + throttle |

Ações separadas permitem que um operador edite rascunho sem publicar. O legado usava só
`read|write`.

## 8. Continuação

Endpoints REST, API Resources, views Blade, fila/cache/storage e os riscos de equivalência estão em
`10-INTERFACES-E-OPERACAO.md`.
