# Newsletters — Regras e Portabilidade

> **Módulo:** `newsletters` (tabela `modulos`, id 53)

---

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

| Regra | Onde está implementada | Consequência prática |
|---|---|---|
| O e-mail é único **em todo o histórico**, inclusive em registros apagados | constraint `UNIQUE` em `newsletters.email` + regra `unique:newsletters,email` | um e-mail excluído no CMS **não** pode se assinar novamente pelo portal |
| A exclusão é sempre lógica | `SoftDeletes` no model; `destroy()` chama `$newsletter->delete()` | a listagem do CMS some com o registro, mas a linha continua na tabela e no CSV de exportação |
| `sent_at` = instante da assinatura | `Carbon::now()` no `store`/`storePortal` | o campo não representa envio de e-mail; nada no repositório envia correspondência |
| `sent_at` é imutável | `update()` grava apenas `name`, `email`, `active` | o campo do formulário é decorativo |
| `ip_address` é capturado no servidor | `$request->ip()` | registra o IP do cliente (respeita `TrustProxies`) |
| `active` nunca é nulo | coluna `NOT NULL` sem default; no CMS vem do par `hidden=0`/`checkbox=1`; no portal de `hidden=true` | a rota pública falha com 500 se a requisição não trouxer `active` |
| O bloco só aparece com **duas** chaves ligadas | `modulos.status` (id 53) **e** `configurations.value = 0` | desabilitar apenas uma delas não esconde o formulário |
| `configurations.newsletters = 1` **oculta**; `0` **mostra** | `getStatusDisplayNewsletters()['displayNewsletters'] == 0` | a semântica do valor é invertida em relação ao nome da chave ("Desabilitar Newsletter?") |
| O layout `default` não tem newsletter | nenhuma ocorrência em `v6/resources/views/portal/layouts/default` | municípios que usam o layout `default` não oferecem o formulário |
| O item de menu exige permissão **e** módulo habilitado | `MenuServiceProvider` (`has_access("newsletter.index")` + `$menu->modulo->status`) | desabilitar o módulo some com o item do CMS |
| O contador `clicks` do menu **não** é atualizado pelo módulo | `NewsLetterController` não chama `menuClicks()` (ao contrário de `ServidorController`) | o item "Newsletter" fica sempre com `clicks = 0`, o que interfere na ordenação quando `menu_interativo` está ativo (`MenuServiceProvider` ordena por `clicks DESC, id ASC`) |

## 2. Acoplamentos

### Dependências do módulo

| Módulo / infra | Forma do acoplamento |
|---|---|
| `modulos` (registro de módulos) | `menu_orders.modulo_id = 53` → `modulos.id`; `modulos.status` decide a exibição no portal e no menu do CMS |
| `configurations` | chave `newsletters` (`UpdateConfigurationOrderSeederNewsletter`) lida por `getStatusDisplayNewsletters()` |
| `menu_orders` | linha `slug = 'newsletter'`; `MenuOrder::modulo()` faz o elo com `modulos` |
| Layouts do portal (`modelo1`..`modelo6`) | cada layout decide onde exibir o formulário; a v7 terá de replicar essa decisão |
| `Prefecture\DatatableController` | serve a listagem JSON; qualquer mudança no formato da view `newsletter` quebra o JS do módulo |
| `CheckRoutePermission` | autorização por nome de rota; renomear rotas muda o contrato de permissão |

### Dependências **do** módulo (quem usa o que o módulo produz)

| Consumidor | Uso |
|---|---|
| Exportação CSV do controller | consome a tabela `newsletters` diretamente |
| Exportação Excel/PDF da DataTable | consome a view `newsletter` (via `DatatableController@get_newsletter`) |
| Nenhum outro módulo do portal lê `newsletters` | a tabela é um ponto final: não há FK de saída |

## 3. Armadilhas

| Armadilha | Evidência | Impacto |
|---|---|---|
| **Classe no plural** | `v6/app/Http/Model/Prefecture/Newsletters.php` define `class Newsletters` | quem procurar `App\Http\Model\Prefecture\NewsLetter` (singular) não encontra nada; a classe `NewsLetter` **não existe** no repositório |
| **Tabela implícita** | o model não declara `protected $table` | o nome da tabela depende do pluralizador; uma refatoração de namespace pode quebrar o mapeamento |
| **Exportação ignora soft delete** | `DB::table('newsletters')->get()` em `NewsLetterController@export` | o CSV entregue ao operador inclui assinaturas excluídas e dados pessoais de quem já pediu remoção |
| **CSV sem escape de fórmulas** | `fputcsv` com valor cru vindo do formulário público | Excel/LibreOffice podem executar fórmulas presentes em `name`/`email` (CSV injection) |
| **`storePortal` sem validação de `active`** | `$request->active` sem `validate()` | POST sem `active` → `QueryException` (coluna `NOT NULL`) → HTTP 500 |
| **`storePortal` sem validação de `name`** | `$request->name` sem `validate()` | é possível gravar `name` nulo (coluna anulável); a DataTable exibe a célula vazia |
| **`index()` carrega a tabela inteira sem usar** | `Newsletters::all()` passado para a view, que só desenha o esqueleto | query completa em memória a cada visita ao CMS (custo desnecessário e risco de exaustão com base grande) |
| **`show()` aponta para uma view inexistente** | `return view('newsletters.show', compact('newsletter'))`; não existe `resources/views/newsletters/show.blade.php` | erro 500 se a ação for chamada; hoje é código morto porque não há rota |
| **`destroy()` devolve 500 para id inexistente** | `findOrFail` lança `ModelNotFoundException`, capturada pelo `catch (\Exception)` | o front recebe `{erro: 'Erro ao deletar inscrição!'}` com HTTP 500 em vez de 404 |
| **Botão de exclusão em lote aponta para a rota errada** | `index.blade.php` usa `['route' => 'ajax.generic.content.deleteAll']` | a exclusão em lote de newsletters chama `Prefecture\ContentController@genericDeleteAll`, que apaga linhas da tabela **`contents`** com os ids selecionados (ver `08-GAPS-E-DIVIDAS.md`) |
| **`update` não altera `ip_address` nem `sent_at`** | `update()` grava só `name`, `email`, `active` | a edição no CMS não corrige o IP nem a data originais |
| **Nome do slug inconsistente** | `modulos.slug = 'newsletters'` (plural) × `menu_orders.slug = 'newsletter'` (singular) × permissão `newsletter.index` | ao recriar o menu é fácil usar o slug errado e o item não aparece |
| **Nome da coluna enganoso** | `sent_at` na tabela e `enviado` na view | sugere data de envio de e-mail, que não existe |
| **Rotas `store`/`update` sem permissão efetiva** | `resourceRoutes` do `CheckRoutePermission` abre qualquer rota cujo nome termine em `.store`/`.update` | ver `08-GAPS-E-DIVIDAS.md` |

## 4. Código duplicado / copiado

| Trecho | Onde | Observação |
|---|---|---|
| Partial de newsletter do portal | `modelo1`..`modelo5` (`layout/sidebar/newsletter.blade.php`) | cinco cópias praticamente idênticas, com pequenas diferenças de markup e de título; o `modelo6` reimplementa o formulário dentro do `footer.blade.php` |
| Bloco de "sucesso/erro + scrollIntoView" | repetido em cada partial do portal | mesmo JavaScript duplicado 6 vezes |
| Consulta da lista | `DatatableController@get_newsletter` é idêntica a `get_servidor` e a dezenas de outras (`DB::table('<view>')->get()` + `struct_views`) | padrão repetido em `v6/app/Http/Controllers/Prefecture/DatatableController.php` |
| JS de DataTable | `assets/js/custom/contents/newsletter/datatables.js` vs `assets/js/custom/contents/servidor/datatable.js` | mesma estrutura, colunas diferentes (inclusive nome de arquivo diferente: `datatables.js` × `datatable.js`) |

## 5. Divergências v5 → v6

O módulo é **funcionalmente idêntico** entre as duas versões. Diff de arquivos:

| Arquivo | Resultado do diff |
|---|---|
| `v5/app/Http/Controllers/Prefecture/NewsLetterController.php` × `v6/app/Http/Controllers/Prefecture/NewsLetterController.php` | idêntico |
| `v5/app/Http/Model/Prefecture/Newsletters.php` × `v6/app/Http/Model/Prefecture/Newsletters.php` | idêntico |
| `v5/database/migrations/2024_10_28_161745_create_newsletters_table.php` × `v6/database/migrations/2024_10_28_161745_create_newsletters_table.php` | idêntico |
| `v5/database/migrations/2024_10_29_154058_create_view_newsletter.php` × `v6/database/migrations/2024_10_29_154058_create_view_newsletter.php` | idêntico |
| `v5/database/migrations/2024_10_30_161709_inserting_menu_orders_newsletter.php` × `v6/database/migrations/2024_10_30_161709_inserting_menu_orders_newsletter.php` | idêntico |
| `v5/resources/views/prefecture/contents/newsletter/index.blade.php` × `v6/resources/views/prefecture/contents/newsletter/index.blade.php` | idêntico |
| `v5/resources/views/prefecture/contents/newsletter/create.blade.php` × `v6/resources/views/prefecture/contents/newsletter/create.blade.php` | idêntico |
| `v5/resources/views/prefecture/contents/newsletter/edit.blade.php` × `v6/resources/views/prefecture/contents/newsletter/edit.blade.php` | idêntico |
| `v5/resources/views/prefecture/contents/newsletter/form.blade.php` × `v6/resources/views/prefecture/contents/newsletter/form.blade.php` | **apenas espaços em branco** (2 linhas) |
| `v6/routes/basepro.php:81-87` × `v5/routes/basepro.php:72-78` | mesmas rotas, mesmas linhas de código (apenas número de linha diferente) |
| `v6/routes/web.php:229` × `v5/routes/web.php` | mesma rota `newsletter.storeportal` |
| `v5/database/seeds/UpdateConfigurationOrderSeederNewsletter.php` × `v6/database/seeds/UpdateConfigurationOrderSeederNewsletter.php` | **diferença real** (ver abaixo) |

### Diferença real: seed de configuração

Na v5 o `update()` final do seed repetia `'key' => 'newsletters'` e `'value' => 0`:

```php
// v5
DB::table('configurations')->where('key', '=', 'newsletters')->update([
    'order' => 27,
    'description' => 'Com esta opção marcada, às Newsletters não irão ser exibidas no portal.',
    'title' => 'Desabilitar Newsletter?',
    'key' => 'newsletters',
    'value' => 0,
    'small_description' => 'Com esta opção marcada, às Newsletters não irão ser exibidas no portal',
    'category_configurations_id' => 1,
]);
```

Na v6 essas duas linhas foram removidas. Efeito: **a opção deixa de ser resetada para `0`**
a cada execução do seed — o que é o comportamento correto (o seed não deve sobrescrever
a escolha do administrador), mas altera o comportamento observável em implantações que
rodam o seed periodicamente.

### Novidade da v6: registro de módulos

- A tabela `modulos` e a migration `2024_11_01_150206_create_modulos_table.php` **não
  existem na v5**. Na v6 o módulo ganhou `modulos.id = 53` (`slug = 'newsletters'`),
  `menu_orders.modulo_id = 53`, o painel `/cms/modulos`
  (`Admin\ModulosController`) e o cache `Cache::rememberForever('modulos')`.
- Na v5 a exibição no portal dependia **apenas** da configuração `newsletters`; na v6 há
  a dupla trava `modulos.status` + `configurations.value`.

### O que **não** mudou (verificado)

- A view SQL `newsletter` é a mesma (inclusive o `row_number()` e o filtro de soft delete).
- O export CSV existia na v5, com o mesmo BOM, os mesmos cabeçalhos e a mesma leitura
  da tabela crua (ou seja, o bug de ignorar o soft delete é **herdado**, não introduzido).
- A rota pública `newsletter.storeportal` e a ausência de validação de `name`/`active`
  também são herdadas.
