# 06 — Plano de Migração para Laravel 11/12 (v7)

Objetivo: reimplementar o módulo `formularios-dinamicos` preservando o **comportamento observável** (mesmos URLs de tela, mesmo formato de planilha, mesmas regras de bloqueio), mas com esquema tipado, validação no servidor e testes.

## 1. Schema de migração Laravel 12

```php
// database/migrations/2025_xx_xx_create_dynamic_forms_tables.php
Schema::create('questionnaires', function (Blueprint $table) {
    $table->id();
    $table->uuid('identifier')->unique();
    $table->boolean('status')->default(true);
    $table->boolean('has_result')->default(false);
    $table->string('title', 100);
    $table->text('description')->nullable();
    $table->text('submit_info')->nullable();
    $table->timestamp('published_at')->nullable();
    $table->string('slug', 100)->nullable()->index();
    $table->softDeletes();
    $table->timestamps();
});

Schema::create('ask_categories', function (Blueprint $table) {
    $table->id();
    $table->foreignId('questionnaire_id')->nullable()->constrained()->cascadeOnDelete();
    $table->foreignId('parent_id')->nullable()->constrained('ask_categories')->cascadeOnDelete();
    $table->unsignedInteger('order')->default(1);
    $table->string('name', 100);
    $table->timestamps();
});

Schema::create('asks', function (Blueprint $table) {
    $table->id();
    $table->uuid('identifier')->unique();
    $table->foreignId('questionnaire_id')->constrained()->cascadeOnDelete();
    $table->foreignId('ask_category_id')->nullable()->constrained()->cascadeOnDelete();
    $table->foreignId('ask_type_id')->constrained()->cascadeOnDelete();
    $table->foreignId('ask_style_id')->nullable()->constrained()->nullOnDelete(); // <- corrige o CASCADE
    $table->unsignedInteger('order')->default(1);
    $table->unsignedInteger('size')->default(12);
    $table->string('title', 100);
    $table->string('placeholder', 100)->nullable();
    $table->text('description')->nullable();
    $table->boolean('required')->default(true);
    $table->string('value', 255)->nullable();
    $table->timestamps();
});

Schema::create('ask_options', function (Blueprint $table) {   // <- substitui config.options
    $table->id();
    $table->foreignId('ask_id')->constrained()->cascadeOnDelete();
    $table->string('value', 191);
    $table->string('text', 191);
    $table->unsignedInteger('order')->default(0);
    $table->timestamps();
});

Schema::create('asks_config', function (Blueprint $table) {   // <- substitui config
    $table->foreignId('ask_id')->primary()->constrained()->cascadeOnDelete();
    $table->decimal('min')->nullable();
    $table->decimal('max')->nullable();
    $table->boolean('is_img')->default(false);
});

Schema::create('filled_questionnaires', function (Blueprint $table) {
    $table->id();
    $table->ipAddress('ip');
    $table->foreignId('questionnaire_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
});

Schema::create('answers', function (Blueprint $table) {       // <- substitui answers JSON
    $table->id();
    $table->foreignId('filled_questionnaire_id')->constrained()->cascadeOnDelete();
    $table->foreignId('ask_id')->constrained()->cascadeOnDelete();
    $table->json('value')->nullable();
    $table->timestamps();
});
```

### 1.1 Estratégia de migração de dados

1. Ler `asks.config` e normalizar: `options` → `ask_options`; `min`/`max` → `asks_config`; `isImg` → `asks_config.is_img`.
2. Ler `filled_questionnaires.answers` (JSON array) e exploding em `answers` (`ask_id` + `value`; `value` array vira várias linhas ou permanece JSON na coluna `value`).
3. Recalcular `identifier` com UUID (o valor antigo é usado como *name* de input, então renomear quebra respostas antigas — ver "decisões de equivalência").
4. Recriar as views SQL como **queries do Eloquent** (ou `DatabaseView` do `spatie/laravel-query-builder`/pacote equivalente) mantendo as colunas `value`, `title`, `asks_count`, `published_at`, `created_at`, `id`, `status`, `has_result` / `responses` para não quebrar o JS do DataTable.

## 2. Modelos

```php
enum AskType: int {
    case TEXT = 1;  case TEXTAREA = 2; case SELECT = 3;  case MULTI_SELECT = 4;
    case FILE = 5;   case MULTI_FILE = 6; case RADIO = 7; case RANGE = 8;
    case CHECKBOX = 9; case SATISFACTION_LEVEL = 10;

    public function usesOptions(): bool {
        return in_array($this, [self::SELECT, self::MULTI_SELECT, self::RADIO], true);
    }
    public function usesFiles(): bool {
        return in_array($this, [self::FILE, self::MULTI_FILE], true);
    }
    public function isMultiValued(): bool {
        return in_array($this, [self::MULTI_SELECT, self::MULTI_FILE], true);
    }
}

enum AskStyle: int {
    case CPF = 1; case CNPJ = 2; case CEP = 3; case DATE = 4; case CASH = 5; case PHONE = 6;
}
```

| Model | Atributos | Casts | Relations |
|---|---|---|---|
| `Questionnaire` | `identifier` (UUID automático no `creating`), `status`, `has_result`, `title`, `description`, `submit_info`, `published_at`, `slug` | `status`/`has_result` → bool, `published_at` → datetime | `categories()`, `asks()`, `filledQuestionnaires()` |
| `AskCategory` | `order`, `name`, `questionnaire_id`, `parent_id` | — | `questionnaire()`, `parent()`, `children()`, `asks()` |
| `Ask` | `identifier` (UUID), `order`, `size`, `title`, `placeholder`, `description`, `required`, `value` | `required` → bool | `questionnaire()`, `askCategory()`, `type()`, `style()`, `options()`, `config()`, `askFiles()` |
| `AskType` / `AskStyle` | `name`, `description`, `icon` | — | `asks()` |
| `FilledQuestionnaire` | `ip`, `questionnaire_id` | `ip` → string | `questionnaire()`, `answers()`, `askFiles()` |
| `Answer` | `filled_questionnaire_id`, `ask_id`, `value` | `value` → array | `filledQuestionnaire()`, `ask()` |
| `AskFile` | `ask_id`, `filled_questionnaire_id`, `title`, `path` | — | `ask()`, `filledQuestionnaire()` |

## 3. Form Requests

| Request | Regras |
|---|---|
| `SaveQuestionnaireRequest` | `title` required max 100; `status` required bool; `has_result` nullable bool; `published_at` required `date_format:d/m/Y H:i`; `slug` nullable max 100 unique ativo; `categories` required array min:1; `categories.*.name` required max 100 distinct; `categories.*.asks` required array min:1; `categories.*.asks.*.ask_type_id` required `Rule::enum(AskType::class)`; `categories.*.asks.*.ask_style_id` nullable `Rule::enum(AskStyle::class)`; `categories.*.asks.*.title` required max 100; `categories.*.asks.*.required` required bool; `categories.*.asks.*.options` required_if tipo em {SELECT, MULTI_SELECT, RADIO} `array`; `categories.*.asks.*.options.*.value` required string; `categories.*.asks.*.options.*.text` required string; `categories.*.asks.*.min`/`max` required_if tipo RANGE, numeric. |
| `UpdateStatusRequest` | `contents_id` required exists |
| `DeleteRequest` | `id` required exists |
| `DeleteByLoteRequest` | `toDelete` required array; `toDelete.*` exists |
| `SaveCategoryRequest` | `name` required max 100; `order` nullable numeric; `parent_id` nullable exists |
| `Portal\SaveFilledQuestionnaireRequest` | `questionnaire_id` required exists; `answers` required array; `answers.*.ask_id` required exists; `answers.*.value` nullable; validação por tipo (checklist), incluindo `required` quando `ask.required`; `g-recaptcha-response` quando configurado. |
| `Portal\SaveAskFileRequest` | `identifier` required exists `asks,identifier`; `title` required; `type` required; `file` required file mimes:pdf,doc,docx,xls,xlsx,ppt,pptx,txt,zip,rar,7z,image max:20480 (20 MB) |

## 4. Policies / autorização

- `QuestionnairePolicy` com `viewAny`, `create`, `update`, `delete`, `deleteAny`, `viewResponses`, `exportResponses` — baseadas no papel Sentinel (`has_access` hoje) ou em `spatie/laravel-permission` na v7.
- `Authorize` middleware aplicado no grupo de rotas do CMS; o portal permanece público.
- Regra de negócio a policy: `update` é negada se a remoção de uma pergunta afetasse respostas existentes (hoje é erro 422 no save — pode virar uma **previsão** de UX, mantendo o 422 como plano B).

## 5. Endpoints REST/JSON sugeridos

### CMS

```
GET    /api/admin/questionnaires                      → index
POST   /api/admin/questionnaires                      → store
GET    /api/admin/questionnaires/{id}                 → show
PUT    /api/admin/questionnaires/{id}                 → update
DELETE /api/admin/questionnaires/{id}                 → destroy
POST   /api/admin/questionnaires/{id}/status           → updateStatus
POST   /api/admin/questionnaires/{id}/result           → updateResult
POST   /api/admin/questionnaires/bulk-delete           → deleteByLote
GET    /api/admin/questionnaires/{id}/responses        → showResponses
GET    /api/admin/questionnaires/{id}/responses/{n}    → showIndividualResponses
GET    /api/admin/questionnaires/{id}/export           → exportSpreadSheet (job)
```

### Portal

```
GET  /api/portal/questionnaires/{slug}                 → formulário (com tipos/estilos/options)
POST /api/portal/questionnaires/{slug}/answers         → save
POST /api/portal/questionnaires/{slug}/files           → upload (retorna ask_file id)
GET  /api/portal/questionnaires/{slug}/results         → resultado agregado (somente has_result)
```

## 6. Recursos de API

`QuestionnaireResource`, `AskResource` (com `options` e `config` já expandidos), `AskCategoryResource`, `FilledQuestionnaireResource` (com `answers` aninhadas e `files` com URL assinada) e `ResultSummaryResource` (agregados prontos para o gráfico — hoje calculados em JS).

## 7. Views / componentes

- Blade component `<x-dynamic-form.questionnaire :questionnaire="$q" />` com subcomponentes por tipo (`<x-dynamic-form.field.text>`, `.select`, `.file`, `.radio`, `.range`, `.checkbox`, `.satisfaction-level`), idênticos aos `components/*.blade.php` atuais.
- Component `<x-dynamic-form.result>` no lugar do JS imperativo de `resultado.js` (o JS atual monta a tela e os gráficos com dados do endpoint `result.list`).
- No CMS: `<x-admin.questionnaire-builder>` com Alpine.js + SortableJS (hoje é JS próprio de ~2.300 linhas entre `structure.js` + `listeners.js` + `helpers.js`).
- Manter os **6 modelos de portal**: cada modelo tem um `questionnaire/edit.blade.php` e um `filled-questionnaire/show.blade.php` — por exemplo `v6/resources/views/portal/layouts/modelo1/dynamics-forms/questionnaire/edit.blade.php` e `v6/resources/views/portal/layouts/modelo1/dynamics-forms/filled-questionnaire/show.blade.php`, com equivalentes em `modelo2`, `modelo3`, `modelo4`, `modelo5` e `modelo6` — ou, de preferência, um único componente include, como já existe em `v6/resources/views/portal/layouts/dynamics-forms/questionnaire/render.blade.php`.

## 8. Fila, cache e storage

| Item | Proposta |
|---|---|
| Exportação XLSX | `ShouldQueue` + `Excel::queue(new ResponsesExport($questionnaireId), $filePath)`; resposta 202 com `job_id`; download via `GET /exports/{uuid}`. |
| Upload de arquivo | `Storage::disk('public')->putFileAs($directory, $file, $name)` com `directory` = `content/formularios-dinamicos/{questionnaire_identifier}/arquivos/{ask_identifier}`; checar MIME real (não só a extensão). |
| Cache | cachear o HTML do formulário público por slug (`Cache::remember`) com invalidação no save do questionário. |
| Limpeza de órfãos | job agendado que remove `ask_files` com `filled_questionnaire_id = null` e mais de N horas, e categorias órfãs (hoje isso acontece por acaso, dentro do laço de save). |
| Gravação de resposta | transação única: `filled_questionnaires` + `answers` + vínculo dos `ask_files`. |

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

| Item | Decisão obrigatória |
|---|---|
| **URL do portal** | Manter `/formularios/{slug}` — é o link permanente publicado pela prefeitura (impresso em cartazes, redes sociais). |
| **Nome do input = `ask.identifier`** | O `identifier` é gerado no banco e usado como `name` do campo. Se mudar para UUID, os links salvos/JS que referenciam `identifier` mudam — aceitável, mas exige migrar `ask_files` e o JS. |
| **Formato do `answers`** | Se normalizar para tabela `answers`, a exportação e o painel de respostas precisam ser reescritos; o formato JSON antigo **deve** ser importado. |
| **Exclusão de pergunta com resposta** | Preservar o 422 com a mensagem "Existe resposta associada ao campo de título: {título}." |
| **Exclusão de questionário** | Manter soft delete (o histórico de respostas é ligado por FK cascade — soft delete **não** apaga as respostas, como hoje). |
| **Slug** | Manter a geração "slug + sufixo numérico" e **restaurar** o índice único no banco (a migration de 2024-12-02 removeu o unique). |
| **Resultado público** | Manter a distinção atual: gráficos de escolha/radio/satisfação são públicos; texto/arquivo/intervalo/checkbox exigem login. Se o negócio quiser total transparência, é decisão do município — não deve mudar sozinho. |
| **Máscaras e validações** | Replicar CPF/CNPJ/data no servidor (regras `Rule::cpf()`, `Rule::cnpj()` disponíveis no Laravel 11/12) para que a obrigatoriedade deixe de depender do navegador. |
| **Ordem de exibição** | Categorias ordenadas por `[parent_id ?? 0, order]` e perguntas por `order` — replicar exatamente. |
| **Exportação** | Manter o layout de colunas: uma coluna por pergunta (título como cabeçalho), excluindo `MULTI_FILE` e `RADIO`; valores de `SELECT`/`MULTI_SELECT` como **texto** da opção; `SATISFACTION_LEVEL` como rótulo do nível escolhido (de "Muito Insatisfeito" a "Muito Satisfeito"); `CHECKBOX` como "Sim"/"Não"; `FILE` como URL (vira hiperlink "Clique aqui"). |

## 10. Ordem sugerida de execução

1. Enums + migrations + models + factories.
2. Importador de dados (config → colunas, answers → tabela).
3. Requests + Policies + Resources + endpoints do CMS.
4. Builder do CMS ( Blade + Alpine/Sortable).
5. Portal: leitura, upload, envio, resultado.
6. Exportação em fila.
7. Testes de caracterização do formato da planilha (comparar célula a célula com o XLSX gerado pela v6).
