# Newsletters — Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

> **Módulo:** `newsletters` (tabela `modulos`, id 53)
> **Objetivo:** reconstruir o módulo preservando o comportamento observável, corrigindo
> os defeitos mapeados em `08-GAPS-E-DIVIDAS.md`.

---

## 1. Schema de migração equivalente

```php
// database/migrations/2025_xx_xx_create_newsletters_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('newsletters', function (Blueprint $table) {
            $table->id();
            $table->string('name')->nullable();
            $table->string('email')->unique();
            $table->string('ip_address');
            $table->timestamp('sent_at');
            $table->boolean('active')->default(false);
            $table->timestamps();
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('newsletters');
    }
};
```

Decisões de equivalência:

- `sent_at` continua **obrigatório** e preenchido pelo servidor (não há data de envio).
- `active` ganha `default(false)` — resolve o 500 descrito em `08-GAPS-E-DIVIDAS.md`
  sem alterar o resultado observável para quem envia o campo.
- A view `newsletter` pode ser **eliminada**: a v7 deve filtrar o soft delete no próprio
  Eloquent (`->whereNull('deleted_at')`), mantendo os mesmos nomes de coluna no JSON
  (`value`, `nome`, `email`, `ip`, `enviado`, `ativo`, `id`) para não quebrar o JS.
  Se a view for mantida por compatibilidade, replicar
  `v6/database/migrations/2024_10_29_154058_create_view_newsletter.php`.

## 2. Model

```php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Newsletter extends Model
{
    use HasFactory, SoftDeletes;

    protected $table = 'newsletters';

    protected $fillable = ['name', 'email', 'ip_address', 'sent_at', 'active'];

    protected $casts = [
        'sent_at' => 'datetime',
        'active'  => 'boolean',
    ];

    protected $hidden = ['ip_address'];
}
```

Pontos de atenção:

- **Nome singular** `Newsletter` (a v6 usa `Newsletters`, plural). A migração de dados
  deve mapear explicitamente a classe antiga, sem depender do pluralizador.
- `protected $table` explícito.
- Cast `active => boolean` elimina a ambiguidade `'1'`/`'0'`/`'true'`.
- `ip_address` em `$hidden` evita vazamento acidental em serializações JSON.

## 3. Form Requests

```php
// app/Http/Requests/StoreNewsletterRequest.php  (portal público)
public function rules(): array
{
    return [
        'email' => ['required', 'email', 'max:191', Rule::unique('newsletters', 'email')],
        'name'  => ['nullable', 'string', 'max:255'],
        'active'=> ['nullable', 'boolean'],
    ];
}
```

```php
// app/Http/Requests/StoreNewsletterAdminRequest.php  (CMS)
public function rules(): array
{
    return [
        'name'   => ['required', 'string', 'max:255'],
        'email'  => ['required', 'email', 'max:191', Rule::unique('newsletters', 'email')],
        'active' => ['nullable', 'boolean'],
    ];
}
```

Regras preservadas:

- `unique:newsletters,email` continua apontando para a **tabela** (não para a view), para
  manter a semântica de "e-mail único no histórico" — inclusive registros apagados.
  Se o negócio quiser permitir o re-cadastro, usar `Rule::unique()->whereNull('deleted_at')`
  **e** remover a constraint `UNIQUE` do banco (migração de dados consciente).
- No update: `Rule::unique('newsletters', 'email')->ignore($newsletter->id)`.

## 4. Autorização (Policies)

Substituir o `check.route.permission` difuso por policies explícitas:

```php
// app/Policies/NewsletterPolicy.php
public function viewAny(User $user): bool   { return $user->can('newsletter.index'); }
public function view(User $user, Newsletter $n): bool { return $user->can('newsletter.edit'); }
public function create(User $user): bool     { return $user->can('newsletter.create'); }
public function update(User $user, Newsletter $n): bool { return $user->can('newsletter.update'); }
public function delete(User $user, Newsletter $n): bool { return $user->can('newsletter.delete'); }
public function export(User $user): bool     { return $user->can('newsletter.export'); }
```

A porta pública **não** deve passar por policy: é o endpoint do portal, protegido por
CSRF e por rate limit.

## 5. Endpoints REST/JSON sugeridos

| Método | URI | Quem usa | Descrição |
|---|---|---|---|
| POST | `/newsletter` | portal público | assina; responde `201 {ok: true}` ou `422` com erros de campo |
| GET | `/api/newsletter` | CMS | lista paginada (DataTables server-side) |
| POST | `/api/newsletter` | CMS | cadastra |
| PUT | `/api/newsletter/{newsletter}` | CMS | edita |
| DELETE | `/api/newsletter/{newsletter}` | CMS | soft delete |
| POST | `/api/newsletter/bulk-delete` | CMS | exclusão em lote (ids no corpo) |
| GET | `/api/newsletter/export` | CMS | CSV |

Endpoints que **não** devem existir na v7:

- `show` da inscrição individual (a v6 tem a ação, mas a view `newsletters.show` não existe).
- Qualquer endpoint que devolva `ip_address` para o público.

## 6. API Resources

```php
class NewsletterResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'sent_at'    => $this->sent_at?->format('d/m/Y H:i:s'),
            'active'     => (bool) $this->active,
            'ip_address' => $this->when($request->user()?->can('newsletter.export'), $this->ip_address),
        ];
    }
}
```

O `ip_address` só sai para quem tem a permissão de exportação — na v6 ele é entregue
a qualquer operador que abra a lista.

## 7. Views Blade modernas

- Componente único `<x-newsletter-form />` (Blade Anonymous Component) usado por
  `modelo1`..`modelo6` e `default`, eliminando as seis cópias do partial e o HTML
  embutido no `footer.blade.php` do modelo6.
- O componente deve ler os dois interruptores e esconder-se sozinho:
  ```blade
  @if ($newsletterEnabled && $newsletterModuleEnabled)
      <x-newsletter-form :action="route('newsletter.store')" />
  @endif
  ```
- O envio pelo portal passa a ser `fetch`/Alpine com resposta JSON, sem
  `redirect()->back()` nem `session('success')`.
- No CMS, a listagem pode passar a ser uma tabela paginada do próprio Laravel
  (Livewire ou Inertia) em vez de Yajra + view SQL.

## 8. Fila, cache e storage

| Item | Situação na v6 | Sugestão v7 |
|---|---|---|
| Cache | `Cache::rememberForever('modulos')` no `ModulosServiceProvider` | manter em cache, mas expor a chave via um serviço `ModuleRegistry` tipado, sem `pluck('status', 'slug')` solto |
| Fila | nenhuma | nada a enfileirar (não há envio de e-mail). Se o negócio passar a disparar campanhas, o envio deve ser um job com `ShouldQueue` |
| Storage | nenhum arquivo | não se aplica |
| Rate limit | ausente | `throttle:10,1` no endpoint público de assinatura, para evitar abuso da base |

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

| Item | Decisão obrigatória |
|---|---|
| E-mail único global (inclusive apagados) | **manter** o comportamento ou mudar com aviso ao negócio — afeta quem já se increveu e foi removido |
| Coluna `sent_at` obrigatória e imutável | manter; se passar a ser "data de envio", é mudança de contrato de dados |
| Nome do arquivo exportado `newsletters.csv` | manter (usuários e scripts dependem do nome) |
| BOM UTF-8 no CSV | manter (Excel sem BOM quebra acentuação) |
| Cabeçalho do CSV (`ID;Nome;E-mail;Endereço IP;Data de Inscrição;Ativo`) | manter a ordem e os rótulos, ou versionar o formato junto com o nome do arquivo |
| Dupla trava de exibição (`modulos.status` + `configurations.newsletters`) | manter as duas, mas centralizar em um único serviço/flag para evitar contradição |
| Formulário ausente no layout `default` | na v7, incluir o componente em **todos** os layouts, inclusive `default` |
| Soft delete | manter; o export **deve** passar a respeitar `deleted_at` (correção, não equivalência) |
| Retorno do portal | `redirect()->back()` + flash pode ser mantido se o portal continuar sendo Blade clássico; migrar para JSON se o portal virar SPA |

## 10. Checklist de migração de dados

1. Renomear a tabela para `newsletter` **ou** manter `newsletters` e declarar
   `protected $table` — as duas opções são válidas; manter `newsletters` reduz o risco.
2. Criar a policy e os requests; remover o `check.route.permission` dessa rota.
3. Mover `getStatusDisplayNewsletters()` para um `NewsletterSettings` (configuração tipada).
4. Substituir a view SQL por query Eloquent ou mantê-la e documentar a dependência.
5. Reescrever o export com escape de fórmulas e filtro de soft delete.
6. Remover a ação `show` e a referência a `view('newsletters.show')`.
7. Remover `Newsletters::all()` do `index()` (a listagem vem do endpoint paginado).
8. Criar a factory `NewsletterFactory` (ver `07-TESTES.md`).
