# Formulários Dinâmicos — Regras e Portabilidade

> **Slug:** `infraestrutura-formulariosdinamicos` · **Versão analisada:** `lotep`
> **Raiz de referência:** `/home/one/projects/maxima/v7/lotep`

---

## 1. Regras de negócio não óbvias

### 1.1 Versionamento

- **Edição no lugar** quando o formulário **não tem nenhuma submissão**: a versão corrente tem os
  campos apagados com `forceDelete()` e recriados, e `scoring_config` é sobrescrito
  (`FormularioCrudService.php:146-169, 198`).
- **Bump** quando existe **qualquer** submissão: cria `version = current_version + 1`, copia o
  `scoring_config` anterior quando nenhum novo foi enviado, avança `current_version` e grava os
  novos campos na versão nova (`FormularioCrudService.php:172-186`).
- Logo, "imutável" é uma regra de **aplicação**, não do banco: nada impede atualizar campos de uma
  versão com submissões via código/`tinker`. O histórico só é preservado porque o caminho de
  escrita do CMS respeita a regra.
- A submissão sempre grava `versao_id`; relatórios usam a versão **ativa** para interpretar
  perguntas, mesmo quando existem submissões de versões antigas. Não há tela de relatório por
  versão.

### 1.2 Slug

- Ao criar: se o slug já existe em formulário **não apagado**, retorna erro de validação.
- Se existe apenas em formulário **soft-deleted**, o antigo é renomeado para
  `{slug}--{8 primeiros hex de md5(microtime)}` e o novo assume o slug. O histórico do antigo é
  preservado (`FormularioDinamicoCmsController.php:112-133`).
- Ao atualizar: o slug novo **não** passa por essa renomeação; uma colisão estoura `QueryException`
  e o controller devolve erro `slug`.
- Não há redirecionamento 301 do slug antigo; a URL velha passa a 404.

### 1.3 Status e expiração

- `scopeActive`: `status = 'active'` **e** (`expires_at` nulo **ou** `expires_at >= now`).
- `published_at` é gravado quando o status vira `active`, tanto no create quanto em qualquer update
  em que o status enviado seja `active` (`FormularioCrudService.php:51,88-90`).
- `archived` não é tratado de forma diferente de `draft` no portal: ambos somem.
- Não há tela para `expires_at`; o campo existe e é respeitado.

### 1.4 Consentimento

- Default `true`. `consent_required = false` desliga a exigência e faz `consent_at` ficar `null`
  mesmo que o payload envie `consent` (`SubmissionService.php:179-186, 104`).
- O erro de consentimento é o **primeiro** da cadeia: sem consentimento, a validação dos campos nem
  roda.
- O portal envia `consent = (bool) $request->input('lgpd_consent', false)`; a API usa `consent`.
  O nome do campo diverge entre as superfícies.

### 1.5 Scoring

O `ScoringService` implementa uma regra única com ramificações por tipo
(`ScoringService.php:25-243`):

| Situação | Pontos | Máximo |
|---|---|---|
| Campo com opções **sem** peso explícito (> 0) | `weight` da pergunta, se respondido; 0 se não | `weight` |
| Campo com opções **com** peso explícito | soma dos pesos das opções selecionadas (multi) ou peso da opção escolhida (única) | multi: soma dos pesos positivos; única: maior peso |
| `range` sem `weight` | valor escolhido | `rules.max` (default 10) |
| `range` com `weight` | `(valor / max) * weight` | `weight` |
| Texto/textarea/file respondido | `weight` | `weight` |
| Não respondido | 0 | conforme o tipo |

- "Peso explícito" = existe **pelo menos uma** opção com `weight > 0`. Basta uma opção positiva
  para o campo inteiro entrar no modo avançado; opções com 0 somam zero.
- `weight` da pergunta **não é editável** no CMS; o editor só grava nota por opção. Portanto, na
  prática, o modo usado é o de opções explícitas.
- `max_score` é a soma dos máximos de **todos** os campos, inclusive não obrigatórios. Um campo
  opcional pulado reduz o score sem reduzir o denominador — comportamento intencional (o percentual
  mede o instrumento inteiro).
- Arredondamentos: `scoreAnswers` arredonda `score`/`max_score` a 2 casas; `scoreMulti` e
  `scoreRange` arredondam a 3 casas antes da soma.

### 1.6 Diagnóstico por faixas

- Sem `bands` → `classify()` retorna `[]`; a submissão é gravada com `result_level = null` e
  `result_data = null`.
- **Percentual:** medida = `floor(((score / max_score) * 100) * 10000) / 10000`; se `max_score = 0`,
  medida = 0. O `floor` evita que ruído de float no teto (ex.: 30.999999999) empurre para a faixa
  seguinte (`PercentBandStrategy.php:25-32`).
- Faixas são ordenadas por `max_percent`; escolhe-se a **primeira** cuja medida `<= max_percent`;
  acima da última, cai na última.
- **Absoluta:** ativada se **qualquer** faixa tiver `max_points`; usa o score bruto e ignora
  `max_percent`. Faixas sem `max_points` são descartadas por `AbsoluteBandStrategy::bands()`
  (`AbsoluteBandStrategy.php:38-50`). Faixas mistas, portanto, perdem as percentuais.
- `disclaimer` do `scoring_config` é anexado ao resultado, se string.
- `normalizeResult` aplica defaults: `level = 'sem-classificacao'`, `title = 'Sem classificação'`,
  `message = ''`, `recomendacoes = []`, `canais = []`.

### 1.7 Faixas no CMS

`faixasToScoringConfig()` (`FormularioDinamicoCmsController.php:553-606`) faz mais do que copiar:

- descarta faixa sem título ou sem `ate` numérico;
- ordena por `de` (ou `ate`, quando `de` ausente);
- **reconstrói** o intervalo: primeira começa em 0, cada seguinte em `prevMax + 1`, `max` limitado a
  100 e nunca menor que o `de`;
- gera `level` sequencial `faixa_1`, `faixa_2`, ... na ordem crescente;
- grava `min_percent`, `max_percent`, `title`, `message`, `color`, `recomendacoes: []`, `canais: []`.
- faixas vazias → `[]` → `scoring_config = null` (sem diagnóstico).

O `level` muda se a ordem mudar: relatórios históricos por `result_level` continuam válidos porque
o valor foi congelado na submissão (`result_data`), mas comparações entre versões podem misturar
rótulos.

### 1.8 Slug do CSV e caracteres

O CSV não neutraliza fórmulas de planilha (`=`, `+`, `-`, `@`). Um texto de resposta começando com
`=` vira fórmula ao abrir no Excel/LibreOffice — risco de *CSV injection* em exportação feita por
operador. Não há aspas extras nem prefixo de apóstrofo.

## 2. Acoplamentos

| Direção | Módulo/Artefato | Como |
|---|---|---|
| Depende de | `V2/Core` | `BaseController`, `V2AuditLogger`, `CmsActionLogger`, `v2_can()`, `v2_uuid()` |
| Depende de | `config/formulario-dinamico.php` | registro de tipos, `default_max_range`, `ip_hash_algo`, `retention_days` |
| É usado por | `V2/Modules/Servicos/AutoTesteJogoResponsavel` | `AutoTesteInstrumentoService::ensure()` cria formulário, versão, 13 campos e faixas |
| É usado por | `V2/Modules/Support/MigracaoLegada` | `FormulariosDinamicosLegacyImporter` cria formulários e submissões via `FormularioCrudService` e models |
| É usado por | testes/e2e | `AutoTesteInstrumentoService::SLUG` é fixture do teste de API |
| Não depende de | módulo legado | nenhum import de `V2\Modules\Infraestrutura\FormulariosDinamicos` no serviço |

O acoplamento com AutoTeste é **de dados**, não de código: o AutoTeste escreve direto nas tabelas
do motor, sem usar o CMS. Um ajuste no schema do motor pode quebrar o instrumento.

## 3. Armadilhas encontradas

### 3.1 O índice GIN não é usado

A migration cria `gin (answers jsonb_path_ops)`, mas nenhuma consulta usa `whereJsonContains`,
operador `@>` ou `answers->>`. As leituras de `answers` são sempre "carregue todas as linhas do
formulário e percorra no PHP". O índice é **custo de escrita sem benefício de leitura**.

### 3.2 Relatórios carregam tudo em memória

```php
$submissions = V2FormularioDinamicoSubmissao::where('formulario_id', $formulario->id)
    ->select('answers')
    ->get();
```

(`ExportService.php:102-106`, repetido em `:188-196`)

Depois percorre cada campo × cada submissão. Para N submissões e M campos o custo é O(N·M) em PHP,
além de N linhas jsonb na memória. `questionScores` ainda chama `ScoringService::score()` por
campo/submissão. Em formulário com dezenas de milhares de respostas, é o gargalo do relatório.

### 3.3 Exportação sem streaming

`export()` carrega todas as submissões (`->get()`) e `toCsv()` monta a string inteira em
`php://temp`. Não há `cursor()`, `lazy()` nem `StreamedResponse`.

### 3.4 Renderização por string e escape disperso

`FieldType::render()` devolve HTML cru; `RendererService::renderForm()` concatena. O escape depende
de cada componente Blade usar `{{ }}`. Os pontos de risco reais são os `{!! !!}`:

- `consentLabel` (HTML vindo do banco);
- `$formBody` (HTML gerado pelos componentes);
- segmentos/legenda do donut no relatório (pré-computados com `e()` aplicado aos textos);
- `implode(' ', $attrs)` nos componentes (atributos montados pelo próprio código).

Não existe uma camada única que garanta "todo texto de configuração sai escapado". Um novo tipo de
campo pode esquecer o escape.

### 3.5 `result_token` não é assinado

O comentário da migration diz "URL assinada do resultado"
(`V2/Services/FormularioDinamico/Migrations/2026_09_28_000004_create_v2_formulario_dinamico_submissoes_table.php:27`), mas o token é `v2_uuid()` — um UUID v4 aleatório de 122 bits,
gerado no `SubmissionService` (`:103`). A busca é igualdade direta:

```php
$row = V2FormularioDinamicoSubmissao::where('result_token', $token)->first();
```

(`SubmissionService.php:226`)

Não há assinatura, expiração, escopo nem invalidação. A proteção é a entropia do UUID + o fato de o
portal comparar `formulario_id`. Quem obtiver o token (histórico, log, referer) acessa a
confirmação, que expõe apenas a mensagem da faixa — não as respostas. Ainda assim, o token é
**credencial de acesso** sem política de validade.

### 3.6 O hash de IP depende de `app.key`

`hash_hmac('sha256', $ip, config('app.key'))` (`SubmissionService.php:208`). Rotacionar `APP_KEY`
torna todos os hashes antigos incomparáveis — não há *pepper* separado nem versionamento de chave.
Também não há normalização de IPv6: o mesmo cliente pode gerar hashes distintos por representação.

### 3.7 `schema` é decorativo

`create()` grava `['campos' => count($campos)]` e `bumpVersion()` grava `['campos' => 0]`. Nenhum
leitor usa `schema`. A documentação do model ("estrutura das perguntas — imutável por versão")
descreve uma intenção que o código não cumpre.

### 3.8 `file` está órfão

`FileFieldType` existe e valida upload, mas:

- **não** está registrado em `config/formulario-dinamico.php` (o mapa tem apenas 8 chaves);
- o editor não oferece o tipo;
- o importador V1 mapeia `FILE`/`MULTI_FILE` para `text`;
- o teste de registro declara explicitamente que `file` foi removido do módulo
  (`FieldTypesTest.php:160-162`).

Resultado: um formulário criado via API/dados com `type = 'file'` falha em
`FormularioDinamicoService::fieldType('file')` com `InvalidArgumentException` — o tipo nunca é
resolvido.

### 3.9 Módulo legado convive com o motor

- Duas permissões (`formularios`, `formulario-dinamico`) para o mesmo módulo canônico.
- Duas famílias de tabelas.
- `POST /formulario/{slug}/submit` grava **IP cru** em `v2_form_submissions.ip` — violação direta
  do princípio aplicado no motor.
- A migration de submissões legadas não compila (tokens colados).
- O teste do módulo (`FormularioCmsControllerTest`) ainda espera views e comportamento antigos;
  ele falha contra o controller atual, que redireciona. É um teste **morto** que descreve o
  passado.

### 3.10 Ordem de rotas do fallback V1

Documentada no próprio teste:

> *"em `V2_IS_DEFAULT=true` o fallback V1 `{slug}` (where `(?!cms).*`) precede rotas V2 de portal na
> ordem de registro"* (`FormularioDinamicoApiTest.php:19-22`)

Ou seja, em ambiente com portal V1 ativo, `/formularios/{slug}` pode ser capturado pelo fallback
antes de chegar ao motor. O e2e cobre o fluxo, o teste HTTP não.

### 3.11 Duplicações

- `isAnsweredValue()` em `ExportService` e `isAnswered()` em `ScoringService` têm a mesma regra.
- Cálculo de percentual com `floor` aparece em `PercentBandStrategy`, `submissoes.blade.php` e
  `relatorios.blade.php`.
- `faixaRows()` (controller) e a normalização de `_faixas-editor` repetem a regra "primeira começa
  em 0, seguinte no teto + 1".
- Normalização de respostas por tipo está no `FieldType` **e** há casts/trim espalhados em
  `FormularioCrudService`.

## 4. Divergências entre motor e módulo legado

| Aspecto | Módulo legado | Motor |
|---|---|---|
| Modelo | formulário + perguntas soltas | formulário + versões + campos por versão |
| Resultado | `has_result` booleano, `result_config` jsonb | `scoring_config.bands` por versão |
| Submissão | `answers` jsonb + `ip` cru | `answers` jsonb + `ip_hash`, `result_token`, `consent_at` |
| Portal | redireciona | renderiza |
| CMS | redireciona (exceto escrita residual) | CRUD completo |
| Permissão | `formularios` | `formulario-dinamico` (ambas → `FormulariosDinamicos`) |
| Categorias | tabela `v2_form_categories` | não existe |
| Anexos | tipo de pergunta FILE na V1 | tipo `file` existe mas não é registrado |

## 5. Regras que **não** podem mudar na reconstrução

1. Submissão referencia a versão usada e o diagnóstico fica congelado (`result_data`).
2. Portal nunca revela pontuação nem nome de faixa.
3. `ip_hash` HMAC, nunca IP cru, e `consent_at` quando exigido.
4. Edição com submissões cria versão nova; sem submissões pode editar no lugar.
5. Faixa percentual usa truncamento para baixo no limite.
6. Sem faixas = sem diagnóstico (agradecimento padrão).
7. Slug apagado pode ser reutilizado preservando o histórico do antigo.
