# Vereadores e Mandatos — Plano de Migração para Laravel Moderno

> Alvo: **PHP 8.x + Laravel 11/12**. Origem: `v6` (Laravel 5.5.50 / PHP 7.2.34).

---

## 1. Princípio de equivalência funcional

A migração é uma **reimplementação**, não uma tradução literal. O que **não pode
mudar** (é contrato com o cidadão e com o negócio):

1. Na listagem pública só aparecem vereadores com `status = true`.
2. Um vereador **com** mandato não pode ser excluído pelo operador.
3. `role` é o **cargo** exibido ao público e é obrigatório na atribuição.
4. `order` define a ordem dos vereadores dentro de um mandato, e essa ordem é
   exatamente a exibida no portal.
5. O caminho público da foto é `/storage/content/vereadores/{vereador_id}/{nome}`.
6. `/vereadores` e `/mandatos` continuam acessíveis por esses caminhos (o portal tem
   atalhos semeados em `v6/database/seeds/LinksTableSeeder.php:388-406`).
7. Um mandato pertence a exatamente uma categoria; a URL da categoria é o `slug`.

O que **pode** mudar (e deve): modelagem física do pivot, tipos, autorização,
organização das views, adição de API e de testes.

## 2. Schema de migração

### 2.1 `mandate_categories`

```php
Schema::create('mandate_categories', function (Blueprint $table) {
    $table->id();
    $table->string('name', 191);
    $table->string('slug', 191)->unique();
    $table->timestamps();
    $table->softDeletes();
});
```

### 2.2 `mandates`

```php
Schema::create('mandates', function (Blueprint $table) {
    $table->id();
    $table->string('name', 191);
    $table->string('slug', 191)->unique();
    $table->unsignedSmallInteger('start_year');
    $table->unsignedSmallInteger('end_year');
    $table->foreignId('mandate_categories_id')
        ->constrained('mandate_categories')
            ->restrictOnDelete();
    $table->boolean('status')->default(false);
    $table->timestamps();
    $table->softDeletes();

    $table->index(['status', 'start_year']);
});
```

### 2.3 `councilmen`

```php
Schema::create('councilmen', function (Blueprint $table) {
    $table->id();
    $table->string('name', 191);
    $table->string('slug', 191)->unique();
    $table->string('party', 191);
    $table->text('text')->nullable();
    $table->foreignId('archives_id')->nullable()->constrained('archives')->nullOnDelete();
    $table->boolean('status')->default(false);
    $table->timestamps();
    $table->softDeletes();

    $table->index(['status', 'name']);
});
```

### 2.4 `councilmen_mandates` — com FKs reais e unique composto

```php
Schema::create('councilmen_mandates', function (Blueprint $table) {
    $table->id();
    $table->foreignId('councilmen_id')->constrained('councilmen')->cascadeOnDelete();
    $table->foreignId('mandates_id')->constrained('mandates')->cascadeOnDelete();
    $table->unsignedInteger('order')->nullable();
    $table->string('role', 191);
    $table->timestamps();
    $table->softDeletes();

    $table->unique(['councilmen_id', 'mandates_id'], 'councilmen_mandates_unique');
    $table->index(['mandates_id', 'order']);
});
```

Melhorias sobre a v6: FKs reais, índice único composto (impede duplicidade),
índice para a ordenação, `softDeletes` coerente com o uso (decisão de produto a
tomar — ver `08-GAPS-E-DIVIDAS.md`) e `status` com default explícito.

### 2.5 Ligações externas

- `contents.councilmen_id`: manter `foreignId(...)->nullable()->constrained('councilmen')->nullOnDelete()`.
- `viap.councilman_id`: manter `constrained('councilmen')->cascadeOnDelete()`.

## 3. Modelos

```php
final class CityCouncilor extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['name', 'slug', 'party', 'text', 'archives_id', 'status'];

    protected function casts(): array
    {
        return [
            'status' => 'boolean',
            'archives_id' => 'integer',
        ];
    }

    public function mandates(): BelongsToMany
    {
        return $this->belongsToMany(Mandate::class, 'councilmen_mandates')
            ->withPivot(['id', 'role', 'order'])
            ->withTimestamps();
    }

    public function archive(): BelongsTo
    {
        return $this->belongsTo(Archive::class, 'archives_id');
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', true);
    }
}
```

```php
final class Mandate extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['name', 'slug', 'start_year', 'end_year', 'mandate_categories_id', 'status'];

    protected function casts(): array
    {
        return ['status' => 'boolean', 'start_year' => 'integer', 'end_year' => 'integer'];
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(MandateCategory::class, 'mandate_categories_id');
    }

    public function councilmen(): BelongsToMany
    {
        return $this->belongsToMany(CityCouncilor::class, 'councilmen_mandates')
            ->withPivot(['id', 'role', 'order'])
            ->withTimestamps()
            ->orderByPivot('order');
    }
}
```

Mudanças obrigatórias em relação à v6:

| Item | v6 | v7 |
|---|---|---|
| `Mandate::categories()` | `hasOne` | `belongsTo` renomeado para `category()` |
| `CouncilmenMandates::councilmen()` | `hasOne` | `belongsTo` |
| `CityCouncilor::archives()` | `hasOne` | `belongsTo` (renomeado `archive()`) |
| `withPivot` | `['id','role']` | `['id','role','order']` |
| `status` | sem cast | `boolean` |
| `CouncilmenMandates` | sem `SoftDeletes` | com `SoftDeletes` (decisão de produto) |

O model `CouncilmenMandates` deve passar a ter `protected $table` explícita e
`casts` para `order`.

## 4. Enums sugeridos

```php
enum MandateRole: string
{
    case President = 'Presidente';
    case VicePresident = 'Vice-Presidente';
    case Secretary = 'Secretário';
    case Councilor = 'Vereador';
    case Substitute = 'Suplente';
}
```

```php
enum Party: string
{
    // gerado a partir da tabela de partidos do município (a v6 trata `party` como texto livre)
}
```

Cuidado: na v6 `role` e `party` são **texto livre** e o operador pode digitar
qualquer valor. Introduzir enum **restringe** a operação. Recomendação: manter
`party` como texto livre (com validação `max:190`) e usar `MandateRole` apenas como
**sugestão** (datalist) ou como tabela de referência, com fallback para texto livre.

## 5. Form Requests

```php
final class StoreCityCouncilorRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:190'],
            'party' => ['required', 'string', 'max:190'],
            'text' => ['nullable', 'string'],
            'status' => ['sometimes', 'boolean'],
            'archives_id' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
            'mandates' => ['sometimes', 'array'],
            'mandates.*.id' => ['integer', Rule::exists('mandates', 'id')],
            'mandates.*.role' => ['required', 'string', 'max:190'],
        ];
    }
}
```

```php
final class StoreMandateRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:190'],
            'slug' => ['nullable', 'string', 'max:191', Rule::unique('mandates', 'slug')->ignore($this->route('mandate'))],
            'mandate_categories_id' => ['required', 'integer', Rule::exists('mandate_categories', 'id')],
            'start_year' => ['required', 'integer', 'min:1900', 'max:2200'],
            'end_year' => ['required', 'integer', 'gte:start_year'],
            'status' => ['sometimes', 'boolean'],
        ];
    }
}
```

A regra `Rule::unique(...)->ignore(...)` corrige o bug da v6 (slug do registro em
edição contava como duplicidade).

## 6. Policies

```php
final class CityCouncilorPolicy
{
    public function viewAny(User $user): bool { return $user->can('vereadores.viewAny'); }
    public function create(User $user): bool { return $user->can('vereadores.create'); }
    public function update(User $user, CityCouncilor $councilor): bool { return $user->can('vereadores.edit'); }
    public function delete(User $user, CityCouncilor $councilor): bool
    {
        if (! $user->can('vereadores.delete')) {
            return false;
        }

        // equivalência funcional: não excluir vereador com mandatos
        return ! $councilor->mandates()->exists();
    }
}
```

```php
final class MandatePolicy
{
    public function viewAny(User $user): bool { return $user->can('mandatos.viewAny'); }
    public function create(User $user): bool { return $user->can('mandatos.create'); }
    public function update(User $user, Mandate $mandate): bool { return $user->can('mandatos.edit'); }
    public function delete(User $user, Mandate $mandate): bool { return $user->can('mandatos.delete'); }
    public function reorder(User $user, Mandate $mandate): bool { return $user->can('mandatos.edit'); }
}
```

A regra "não excluir com mandatos" sai do controller e passa para a **Policy** — testável
e não contornável por rota.

## 7. Endpoints REST e API Resources

### 7.1 API JSON (nova)

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/vereadores` | Lista paginada de vereadores ativos (`?status=`, `?party=`, `?q=`) |
| GET | `/api/v1/vereadores/{id}` | Detalhe |
| GET | `/api/v1/vereadores/{id}/mandatos` | Mandatos do vereador (com `role` e `order`) |
| GET | `/api/v1/mandatos` | Mandatos, filtrável por `category` |
| GET | `/api/v1/mandatos/{id}/vereadores` | Vereadores do mandato, ordenados por `order` |
| GET | `/api/v1/mandate-categories` | Categorias |

```php
final class CityCouncilorResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'slug' => $this->slug,
            'party' => $this->party,
            'bio' => $this->text,
            'active' => (bool) $this->status,
            'photo_url' => $this->archive?->url ?? url('/assets/images/perfil.jpg'),
            'mandates' => MandateResource::collection($this->whenLoaded('mandates')),
        ];
    }
}
```

### 7.2 CMS (rotas web)

| Método | URI | Nome |
|---|---|---|
| GET | `/cms/vereadores` | `vereadores.index` |
| GET | `/cms/vereadores/create` | `vereadores.create` |
| POST | `/cms/vereadores` | `vereadores.store` |
| GET | `/cms/vereadores/{councilor}` | `vereadores.show` |
| GET | `/cms/vereadores/{councilor}/edit` | `vereadores.edit` |
| PUT | `/cms/vereadores/{councilor}` | `vereadores.update` |
| DELETE | `/cms/vereadores/{councilor}` | `vereadores.destroy` |
| POST | `/cms/vereadores/{councilor}/mandatos` | `vereadores.mandatos.store` |
| DELETE | `/cms/vereadores/{councilor}/mandatos/{pivot}` | `vereadores.mandatos.destroy` |
| GET/POST/PUT/DELETE | `/cms/mandatos[...]` | `mandatos.*` |
| PATCH | `/cms/mandatos/{mandate}/councilmen/order` | `mandatos.councilmen.order` |
| GET/POST/PUT/DELETE | `/cms/mandate-categories[...]` | `mandate-categories.*` |

A ordenação deixa de ser um POST genérico e vira um recurso explícito:

```php
public function order(UpdateCouncilmenOrderRequest $request, Mandate $mandate): JsonResponse
{
    $ids = collect($request->validated('order'))->map(fn ($id) => (int) $id);

    DB::transaction(function () use ($ids, $mandate) {
        foreach ($ids as $position => $id) {
            CouncilmenMandates::query()
                ->where('mandates_id', $mandate->id)
                ->whereKey($id)
                ->update(['order' => $position + 1]);
        }
    });

    return response()->json(['status' => 'ok', 'count' => $ids->count()]);
}
```

## 8. Views modernas

- **Componente único** `<x-councilman-card :councilman="$c" />` e
  `<x-councilman-profile>` — elimina as 12 cópias de `trata_nome()`.
- **Helper** `str_slug` nativo (`Str::slug`) + serviço `SlugService` que resolve a
  unicidade em um único lugar (`Str::slug($name).'-'.Str::random(5)`), substituindo a
  repetição nos 3 controllers.
- Blade + Tailwind/Bootstrap 5 + Alpine.js para o acordeão e o carrossel
  (substituindo lightSlider e jQuery UI `sortable`).
- Livewire/SortableJS para a ordenação por arrastar e soltar.
- Componente `<x-avatar :src="..." :fallback="'/assets/images/perfil.jpg'" />` para
  padronizar o caminho da foto.

## 9. Storage, fila e cache

```php
// Controller
$path = $request->file('photo')->store("content/vereadores/{$councilor->id}", 'public');
$councilor->update(['archives_id' => $archive->id, 'photo_path' => $path]);
```

- Usar `Storage::disk('public')` explicitamente e o link `storage:link`.
- Guardar o caminho real no `archives.path` (elimina o *fallback* de string do
  carrossel).
- **Fila**: job `GenerateCouncilmanThumbnail` para gerar miniatura quadrada (o
  carrossel corta a foto em círculo) e registrar em `archive_variants`.
- **Cache**: `Cache::remember('portal.vereadores.active', 300, ...)` para a lista
  pública; invalidado por eventos de modelo.

## 10. Testes

Ver `07-TESTES.md`.

## 11. Riscos de migração de dados

| Risco | Impacto | Mitigação |
|---|---|---|
| `councilmen_mandates` sem FK e sem unique pode conter órfãos/duplicatas | Importação falha na criação das FKs | Script de saneamento antes da migration: remover `councilmen_id`/`mandates_id` inexistentes e duplicar pares |
| `role` NOT NULL com linhas antigas | idem | Confirmar que todas as linhas têm `role` preenchida (verificar) |
| `order` NULL em linhas antigas | Ordenação inconsistente | `UPDATE … SET order = row_number() OVER (PARTITION BY mandates_id ORDER BY id)` |
| `slug` duplicado | Criação de índice unique falha | Desambiguar com sufixo antes de criar o índice |
| `contents.councilmen_id` / `viap.councilman_id` apontando para deletados | FK falha | Filtrar `deleted_at IS NULL` antes de criar as constraints |
| Enum de `role` | Perde valores arbitrários | Manter texto livre |
| Dependência do `id` na URL `/vereadores/{id}` | Links externos quebram se o id mudar | Manter o id ou criar rota por slug com redirecionamento |
