# 05 — Regras de Negócio, Acoplamentos e Armadilhas

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

### 1.1 Slug único "soft" e link permanente

`SaveQuestionnaireController` gera o slug com `str_slug()` e, se já existir, anexa um sufixo numérico recursivo:

```php
$checkIfSlugExist = function ($slug) use (&$checkIfSlugExist, $id) {
    $count = Questionnaire::query()->where('slug', $slug)->where('id', '<>', $id)->count();
    if($count > 0) {
        $array = explode('-', $slug);
        $number = end($array);
        if(is_numeric($number)) {
            $count = (int) $number + 1;
            $slug = implode('-', array_slice($array, 0, -1));
        }
        return $checkIfSlugExist($slug . '-' . $count);
    }
    return $slug;
};
```

Mesmo assim a migration `2024_12_02_052824_drop_unique_on_slug_column_questionnaires_table.php` **removeu** o índice único do banco. A unicidade passa a ser garantida só pela aplicação — e só no momento do save (duas abas do CMS podem colidir).

Se o título vier vazio, o slug é gerado a partir do título; se o título não vier, o `slug` do request é usado.

### 1.2 Categoria não pode ficar sem pergunta

`SaveQuestionnaireController` aborta o save com 422 se alguma categoria vier sem `asks`:

```php
if(!$asks || count($asks) == 0) {
    throw new HttpResponseException(
        response()->json(
            ['errors' => ['asks' => 'Não foram encontradas as perguntas do questionário.']],
            Response::HTTP_UNPROCESSABLE_ENTITY
        )
    );
}
```

E `DeleteCategoryController` recusa (422) apagar categoria que tenha perguntas:

```php
if($category->asks()->count() > 0) {
    throw new HttpResponseException(
        response()->json(
            ['errors' => ['category' => 'Não é possível deletar a categoria pois existem perguntas associadas a ela.']],
            Response::HTTP_UNPROCESSABLE_ENTITY
        )
    );
}
```

### 1.3 Pergunta com resposta não pode ser removida

Antes de apagar as perguntas que saíram do formulário, o controller varre o JSON `answers` de todas as respostas do questionário:

```php
$answers = $filledQuestionnaires->map(function($row) {
    return data_get($row, 'answers');
})->collapse();
$askIdFoundInAnswers = $answers->filter(function($row) use ($needDeleteAskIds) {
    return in_array(data_get($row, 'ask_id'), $needDeleteAskIds);
})->pluck('ask_id')->toArray();
if(count($askIdFoundInAnswers) > 0) {
    $firstFound = $askIdFoundInAnswers[0];
    $askTitle = '';
    $ask = Ask::query()->find($firstFound);
    if($ask){
        $askTitle = data_get($ask, 'title');
    }
    throw new HttpResponseException(
        response()->json(
            ['errors' => ['asks' => "Existe resposta associada ao campo de título: $askTitle."]],
            Response::HTTP_UNPROCESSABLE_ENTITY
        )
    );
}
```

Consequência prática: **editar um questionário já respondido é travado** se qualquer pergunta for removida. Como o `answers` é JSON, essa busca é feita em PHP (todos os registros carregados na memória).

### 1.4 Limpeza de categorias órfãs

Dentro do laço de categorias, ao final:

```php
/**
 * Deleta todas as categorias criadas a mais de 1 dia e que não possuem questionário associado.
*/
AskCategory::query()
    ->whereDate('created_at', '<=', now()->subDays(1))
    ->where('questionnaire_id', null)
    ->delete();
```

Categorias criadas pelo "crud rápido" (`/categoria`) nascem com `questionnaire_id = null` (o `SaveCategoryController` não seta `questionnaire_id`) e são órfãs até serem anexadas a um questionário.

### 1.5 `description` vazia do Summernote

```php
if($description == '<p><br></p>') {
    $description = null;
}
```

### 1.6 Data de publicação em formato dd/mm/aaaa hh:mm

`published_at` chega como `d/m/Y H:i` (validação `date_format:d/m/Y H:i`) e o model converte:

```php
public function setPublishedAtAttribute($value)
{
    list($date, $time) = explode(' ', $value);
    list($hour, $minute) = explode(':', $time);
    list($day, $month, $year) = explode('/', $date);
    $this->attributes['published_at'] = \Carbon\Carbon::create($year, $month, $day, $hour, $minute)->format('Y-m-d h:i:s');
}
```

Atenção: `format('Y-m-d h:i:s')` usa `h` (12 horas) em vez de `H` (24 horas) — **bug real**: 18:00 vira 06:00 no banco. O accessor de leitura usa `H:i`.

### 1.7 Status e resultado são alternados, não definidos

`UpdateStatusQuestionnaireController` e `UpdateResultQuestionnaireController` invertem o booleano atual; não existe rota para definir o valor. Toda a gestão é por toggle.

### 1.8 Exclusão em lote registra atividade

```php
activity()->performedOn(new Questionnaire)
    ->causedBy(Sentinel::getUser())
    ->withProperty('Detalhe', 'Os Questionários com ids: ' . implode(",", $request->toDelete) . ' Foram Excluadas por: ' . Sentinel::getUser()->name)
    ->log('Exclusão de questionários em lote');
```

A exclusão individual (`destroy`) **não** registra atividade — só a em lote.

### 1.9 Recaptcha condicional

`Portal\DynamicForms\FilledQuestionnaire\SaveRequest` só exige `g-recaptcha-response` se as duas chaves existirem em `Configuration`:

```php
$recaptchaPublicKey = Configuration::query()->where('key', 'recaptcha_public_key')->first()->value ?? null ;
$recaptchaPrivateKey = Configuration::query()->where('key', 'recaptcha_private_key')->first()->value ?? null ;
if($recaptchaPublicKey && $recaptchaPrivateKey){
    $rules['g-recaptcha-response'] = 'recaptcha';
}
```

Como o `Recaptcha::render()` é exibido **sempre** na view do portal, se o município desabilitar o reCAPTCHA o campo continua na tela mas não é exigido (e vice-versa: com as chaves, a view mostra o widget).

### 1.10 Exibição do resultado depende de `status` e `has_result`

`FilledQuestionnairesController@show` só entrega o questionário se `status = true` **e** `has_result = true`; caso contrário, 404.

## 2. Acoplamentos

| Depende de | Onde |
|---|---|
| `App\Prefecture\Configuration` (tabela `configurations`) | `layout` (via `getTemplateActived()`), `recaptcha_public_key`, `recaptcha_private_key`. |
| `cartalyst/sentinel` | `Sentinel::getUser()` no log de atividade; `Sentinel::check()` para decidir `$isAuth` na tela de resultado. |
| `spatie/laravel-activitylog` | `activity()` nas exclusões em lote. |
| `maatwebsite/excel` | Exportação XLSX. |
| `laracasts/flash` | `flash('Questionário salvo com sucesso.')->success()` / `->error()` nas telas do CMS. |
| `Prefecture\DatatableController` | Listagens paginadas (`get_questionnaire_list`, `get_filled_questionnaire_list`) + view SQL. |
| Módulo **Revisões** | Compartilha a pasta `assets/css/custom/contents/formularios-dinamicos/` e o padrão de "painel de respostas" (`revisoes/formularios/**`); é o módulo que mais se parece com este. |
| `has_access()` (`v6/app/Library/Helper/appHelpers.php`) | Autorização fina dentro do Blade. |

Dependências inversas: o módulo **não** é usado por outros. Ele é Autônomo, exceto pelo registry de módulos (`modulos`, `resources`, `menu_orders`).

## 3. Armadilhas e riscos técnicos

| # | Armadilha | Evidência | Impacto |
|---|---|---|---|
| 1 | `answers` JSON sem validação estrutural | `SaveFilledQuestionnaireController` só copia `$request->get($ask->identifier)` | Cliente pode enviar `value` de tipo inesperado; o servidor nunca valida formato/tamanho |
| 2 | Obrigatoriedade só no cliente | `form-utils.js` (`validate()`) | Envio direto por HTTP burla o required |
| 3 | `identifier` com `rand()`/`uniqid()` | `Ask::boot()` e `Questionnaire::boot()` | Colisão teoricamente possível; `uniqid()` é previsível (microtime) |
| 4 | `ask_style_id` nullable com ON DELETE CASCADE | `v6/database/migrations/2024_03_27_190914_create_asks_table.php` | Apagar um estilo apaga todas as perguntas que o usam |
| 5 | `questions` (módulo de enquetes) × `asks` (este módulo) | Tabelas distintas, prefixos diferentes | Confusão conceitual; o SQL de `listagem_questionarios` referencia `asks` |
| 6 | `str_slug()` (Laravel 5.5) | `v6/vendor/laravel/framework/src/Illuminate/Support/helpers.php:982` | Removido no Laravel 6+ → `Str::slug` |
| 7 | `published_at` gravado com `h:i:s` (12h) | `Questionnaire::setPublishedAtAttribute` | Horário de publicação errado para PM/horário da tarde |
| 8 | Upload antes do vínculo com a resposta | `AskFileController@save` grava `ask_files` com `filled_questionnaire_id = null` | Arquivo órfão se o cidadão desistir; o `ON DELETE CASCADE` de `ask_files.filled_questionnaire_id` não limpa os órfãos |
| 9 | Exportação síncrona | `Excel::store(new ResponsesExport($questionnaireId), $filePath)` dentro do controller | Timeout em questionários com muitas respostas |
| 10 | `mkdir` do diretório de export | `FilledQuestionnaireController@exportSpreadSheet` | Falha se `storage/app/public` não existir |
| 11 | N+1 no painel de respostas | `showIndividualResponses` carrega todos os `filledQuestionnaires` e filtra em PHP | Cresce linearmente com o número de respostas |
| 12 | Carga de todos os `answers` na memória ao salvar | `SaveQuestionnaireController` (`->get()` + `collapse()`) | Memória e tempo crescem com o número de respostas |
| 13 | `required_if` apontando para campo inexistente (`style`) | `Questionnaire\SaveRequest` | `config.options`/`min`/`max` nunca são obrigatórios no servidor |
| 14 | `AskType`/`AskStyle` com `$fillable = ['ask_id']` | models | `AskType::create()` não gravaria `name`; por isso os seeds usam `DB::table()->updateOrInsert()` |
| 15 | Hardcode de rota do portal no JS | `script.js` e `form-utils.js` (`/formularios/fill-questionnaire/save`, `/formularios/ask-file`) | Quebra se a rota mudar; a view Blade usa `route('contato.enviar')` como action do form |
| 16 | `Questionnaire` tem `SoftDeletes` mas `asks`/`categories`cascadeiam por FK | migrations | Soft delete **não** remove perguntas; elas ficam órfãs no banco |
| 17 | Sem rota de "duplicar questionário" / "importar" | rotas do módulo | — |
| 18 | `menuClicks('formulario-dinamico')` nas duas listagens | controllers `index()` | Contador compartilhado entre "Formulários" e "Formulários Preenchidos" |

## 4. Código duplicado / copiado

- **v5 → v6**: **nenhuma** diferença no PHP do módulo. `diff -r` entre os diretórios `Model/Prefecture/DynamicForms`, `Controllers/Prefecture/DynamicForms`, `Controllers/Portal/DynamicForms`, `Requests/Prefecture/DynamicForms`, `Requests/Portal/DynamicForms`, `Http/Resources/Prefecture/DynamicForms`, `Exports/DynamicForms`, `Enum/Prefecture/DynamicForms` e `database/seeds/DynamicForms` retorna vazio nos dois sentidos.
- **Dentro do módulo**: os dois controllers de portal e os dois controllers de CMS repetem o bloco de tratamento de `HttpResponseException` e de `DB::beginTransaction()/rollBack()/commit()` — 7 classes com o mesmo padrão.
- `SaveFilledQuestionnaireController` (Prefecture) é reutilizado pelo portal (`FilledQuestionnairesController@save` do portal), assim como `ShowFilledQuestionnaireController`.

## 5. Onde a v6 divergiu da v5

| Arquivo | Diferença |
|---|---|
| `v5/resources/views/prefecture/dynamics-forms/questionnaire/form.blade.php` vs `v6/resources/views/prefecture/dynamics-forms/questionnaire/form.blade.php` | Só o *cache-busting* dos scripts: v5 usa `structure.js?v=0.0.8` e `listeners.js?v=0.0.7`; v6 usa `?v=0.0.7` e `?v=0.0.6` (a v5 está "à frente" na versão de cache). |
| `v5/resources/views/portal/layouts/modelo1/dynamics-forms/filled-questionnaire/show.blade.php` vs `v6/resources/views/portal/layouts/modelo1/dynamics-forms/filled-questionnaire/show.blade.php` | A v6 reescreveu o cabeçalho da tela de resultado: adiciona `addthis_inline_share_toolbox`, `container`/`justify-content-md-center` e tira a condição `@if (data_get($questionnaire, 'description'))`. As demais telas (modelo2..modelo6, `edit.blade.php`) também diferem entre v5 e v6 por ajustes de layout. |
| Rotas | As rotas do módulo são as mesmas em v5 e v6 (`v5/routes/basepro.php:483-511` e `:571-576`); a v6 apenas adicionou rotas de outros módulos. |

> Observação: na v5 o `form.blade.php` referencia versões de script **maiores** que as da v6 — indica que a branch v6 diverge da v5 e que alterações do construtor de questionários podem ter sido perdidas no caminho. `[NÃO VERIFICADO]` não há histórico de commits disponível neste repositório para confirmar a direção do fork.
