# Sessões da Câmara Municipal — Migração para Laravel Moderno

> Plano de reconstrução do módulo `sessoes` em **PHP 8.x + Laravel 11/12**.
> O objetivo é preservar integralmente a **semântica observável** descrita em
> `05-REGRAS-E-PORTABILIDADE.md`.

---

## 1. Decisões de equivalência funcional (o que NÃO pode mudar)

| # | Comportamento legado | Regra para a v7 |
|---|---|---|
| E1 | Listagem pública mostra apenas `status = true` **e** `publication_date <= now`. | Manter exatamente; usar um `scope` único reaproveitado por listagem, detalhe e widget. |
| E2 | Paginação de 12 itens por página na listagem pública. | Manter 12 (ou parametrizar, com 12 como padrão). |
| E3 | Ordenação `publication_date DESC`. | Manter. |
| E4 | Detalhe acessível por `slug`, restrito à categoria de sessões; 404 caso contrário. | Manter, mas com **canônico único** definido (ver 8). |
| E5 | Galeria de fotos só aparece com o flag de galeria ativo. | Manter. |
| E6 | Vídeo em *embed* gerado a partir da URL do link único. | Manter os formatos YouTube, Vimeo, Facebook e Instagram, com o mesmo fallback. |
| E7 | Sessão sem data futura não aparece — a data funciona como agendamento. | Manter. |
| E8 | Semântica de rascunho: `status = false` = não publicado. | Manter, agora como `is_published`. |
| E9 | Data de publicação exibida em `DD/MM/YYYY às HH:MI` no CMS e `d.m.Y` / `d/m/Y às H:i` no portal. | Manter os formatos. |
| E10 | Link único por sessão (apenas um vídeo). | Manter (coluna única ou `hasOne` explícito). |

**Mudança aceita (com communicated ao negócio):** o detalhe passa a aplicar o
mesmo filtro de publicação da listagem (hoje uma sessão em rascunho é
acessível por URL direta — ver `08-GAPS-E-DIVIDAS.md`, item B1). Se o negócio
quiser preservar o acesso direto a rascunhos, criar uma rota separada e
autenticada.

## 2. Schema de migração Laravel

```php
// database/migrations/2025_xx_xx_create_sessions_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('sessions', function (Blueprint $table) {
            $table->id();
            $table->string('title', 150);
            $table->string('slug', 191)->unique();
            $table->longText('body');                    // HTML do Summernote
            $table->string('session_type', 60)->default('ordinaria'); // enum da sessão
            $table->dateTime('starts_at');               // data/hora da sessão
            $table->dateTime('ends_at')->nullable();     // fim (opcional)
            $table->string('video_url', 2999)->nullable();
            $table->boolean('is_published')->default(false);
            $table->boolean('has_gallery')->default(false);
            $table->foreignId('cover_image_id')->nullable();   // media
            $table->string('session_number')->nullable();      // número da sessão (ex.: "12ª Sessão Extraordinária")
            $table->timestamps();
            $table->softDeletes();

            $table->index(['is_published', 'starts_at']);
        });
    }

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

### 2.1 Tabelas auxiliares

```php
// galeria da sessão
Schema::create('session_images', function (Blueprint $table) {
    $table->id();
    $table->foreignId('session_id')->constrained('sessions')->cascadeOnDelete();
    $table->foreignId('media_id')->constrained('media')->cascadeOnDelete();
    $table->unsignedInteger('position')->default(0);
    $table->timestamps();
});
```

A tabela genérica `media` substitui `archives` com `media_type` (`imagem`,
`documento`, `pdf`) e `is_cover`.

### 2.2 Enums sugeridos

```php
enum SessionType: string
{
    case Ordinaria = 'ordinaria';
    case Extraordinaria = 'extraordinaria';
    case Solene = 'solene';
    case Especial = 'especial';
}
```

### 2.3 Migração dos dados a partir de `contents`

```php
// (ilustrativo)
DB::table('contents')
    ->join('category_contents', 'contents.category_contents_id', '=', 'category_contents.id')
    ->where('category_contents.slug', 'sessoes')
    ->whereNull('contents.deleted_at')
    ->orderBy('contents.id')
    ->chunk(500, function ($rows) {
        foreach ($rows as $row) {
            $session = Session::create([
                'title'        => $row->title,
                'slug'         => $row->slug,
                'body'         => $row->text,
                'starts_at'    => $row->publication_date,
                'is_published' => (bool) $row->status,
                'has_gallery'  => (bool) $row->galery,
            ]);

            // destaque
            $cover = DB::table('archives')->where('contents_id', $row->id)
                ->where('highlight', true)->first();
            if ($cover) {
                $session->cover_image_id = Media::fromArchive($cover)->id;
                $session->save();
            }

            // galeria
            DB::table('archives')->where('contents_id', $row->id)
                ->whereNull('highlight')->orderBy('order')
                ->get()->each(fn ($a) => $session->images()->create([
                    'media_id' => Media::fromArchive($a)->id,
                    'position' => $a->order ?? 0,
                ]));

            // vídeo
            $link = DB::table('links')->where('contents_id', $row->id)->first();
            if ($link) {
                $session->video_url = $link->link;
                $session->save();
            }
        }
    });
```

**Ponto de atenção:** o `slug` legado é único apenas por `contents`; a view
` sessoes` não garantia unicidade. Antes de criar o índice `unique`, resolver
duplicatas (sufixo `-{id}`, como o legado fazia no `store`).

## 3. Modelos

### 3.1 `Session`

```php
namespace App\Models;

class Session extends Model
{
    use HasFactory, SoftDeletes, HasMedia;

    protected $fillable = [
        'title', 'slug', 'body', 'session_type', 'starts_at', 'ends_at',
        'video_url', 'is_published', 'has_gallery', 'cover_image_id', 'session_number',
    ];

    protected function casts(): array
    {
        return [
            'session_type' => SessionType::class,
            'starts_at'    => 'datetime',
            'ends_at'      => 'datetime',
            'is_published' => 'boolean',
            'has_gallery'  => 'boolean',
        ];
    }

    /** Escopo único de publicação — reusado por listagem, detalhe e widget. */
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('is_published', true)
                     ->where('starts_at', '<=', now());
    }

    public function images(): HasMany
    {
        return $this->hasMany(SessionImage::class)->orderBy('position');
    }

    public function coverImage(): BelongsTo
    {
        return $this->belongsTo(Media::class, 'cover_image_id');
    }

    public function getVideoEmbedUrlAttribute(): ?string
    {
        return $this->video_url ? VideoEmbed::url($this->video_url) : null;
    }
}
```

### 3.2 `VideoEmbed` (service)

Substitui `embedVideo()` (`v6/app/Helpers/functions.php:607`):

```php
namespace App\Support;

final class VideoEmbed
{
    public static function url(?string $link): ?string
    {
        if (! $link) return null;
        if (! preg_match('~^https?://~i', $link)) {
            $link = 'https://' . ltrim($link, '/');
        }

        if (preg_match('%(?:youtube(?:-nocookie)?\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^"&?/ ]{11})%i', $link, $m)) {
            return "https://www.youtube.com/embed/{$m[1]}";
        }
        if (preg_match('/vimeo\.com\/(?:.*\/)?([0-9]{6,12})(?:$|[?\/])/i', $link, $m)) {
            return "https://player.vimeo.com/video/{$m[1]}";
        }
        if (stripos($link, 'facebook.com') !== false) {
            return 'https://www.facebook.com/plugins/video.php?href=' . urlencode($link) . '&show_text=false';
        }
        if (stripos($link, 'instagram.com') !== false) {
            $clean = preg_replace('~(\?.*)$~', '', rtrim($link, '/'));
            return preg_match('~/(embed|amp)$~i', $clean) ? $clean . '/' : $clean . '/embed/';
        }

        return $link;
    }
}
```

### 3.3 `SessionImage` e `Media`

```php
class SessionImage extends Model
{
    protected $fillable = ['session_id', 'media_id', 'position'];

    public function session(): BelongsTo { return $this->belongsTo(Session::class); }
    public function media(): BelongsTo  { return $this->belongsTo(Media::class); }
}
```

### 3.4 Casts obrigatórios

O model legado `Content`
(`v6/app/Http/Model/Prefecture/Content.php`) **não** declara casts para
`status` e `galery`. Na v7, `is_published` e `has_gallery` devem ser
`boolean` explícitos, porque o template compara `$session->galery === true`
(estrito) — sem cast, um `1` inteiro quebraria a galeria silenciosamente.

## 4. Form Requests

```php
class StoreSessionRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Session::class);
    }

    public function rules(): array
    {
        return [
            'title'         => ['required', 'string', 'max:150'],
            'slug'          => ['nullable', 'string', 'max:191', 'unique:sessions,slug'],
            'body'          => ['required', 'string'],
            'session_type'  => ['required', Rule::enum(SessionType::class)],
            'starts_at'     => ['required', 'date'],
            'ends_at'       => ['nullable', 'date', 'after_or_equal:starts_at'],
            'video_url'     => ['nullable', 'url', 'max:2999'],
            'is_published'  => ['boolean'],
            'has_gallery'   => ['boolean'],
            'cover_image'   => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:20000'],
            'images'        => ['nullable', 'array', 'max:60'],
            'images.*'      => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:20000'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required'         => 'Por favor, informe um título.',
            'publication_date.required' => 'Por favor, informe a data de publicação.',
            'text.required'          => 'Por favor, digite algum texto.',
            'cover_image.max'        => 'Por favor, selecione uma imagem com tamanho menor que 20MB.',
        ];
    }
}
```

`UpdateSessionRequest` herda de `StoreSessionRequest` e troca
`unique:sessions,slug` por `Rule::unique('sessions','slug')->ignore($this->route('session'))`.

> No legado, o `ContentRequest` (caso `sessoes`) **não** exige `slug`, e o
> slug é gerado automaticamente no `store` e preservado no `update`. A v7 pode
> manter esse comportamento com um mutador no model, em vez de expor o campo.

## 5. Policies

```php
class SessionPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->hasPermissionTo('sessoes.index') || $user->hasRole('super-admin');
    }

    public function create(User $user): bool  { return $user->hasPermissionTo('sessoes.create'); }
    public function update(User $user): bool  { return $user->hasPermissionTo('sessoes.edit'); }
    public function delete(User $user): bool  { return $user->hasPermissionTo('sessoes.delete'); }
    public function restore(User $user): bool { return $user->hasPermissionTo('sessoes.restore'); }
    public function publish(User $user): bool { return $user->hasPermissionTo('sessoes.publish'); }
}
```

Registro em `AuthServiceProvider` / `AppServiceProvider::boot()`:

```php
Gate::policy(Session::class, SessionPolicy::class);
```

**Equivalência com o legado:** `has_access()`
(`v6/app/Library/Helper/appHelpers.php:22`) usa `Sentinel::hasAccess` /
`hasAnyAccess` e o papel `super-admin` passa em tudo. A Policy acima preserva
exatamente esse contrato, trocando apenas o provedor de identidade.

## 6. Endpoints REST / API

### 6.1 Rotas públicas (Blade)

```php
Route::get('/sessoes', [PublicSessionController::class, 'index'])->name('sessoes.index');
Route::get('/sessoes/{session:slug}', [PublicSessionController::class, 'show'])->name('sessoes.show');
```

### 6.2 Rotas administrativas (CMS)

```php
Route::middleware(['auth', 'can:sessoes.index'])->prefix('cms/sessoes')
    ->name('admin.sessoes.')->group(function () {
        Route::get('/', [Admin\SessionController::class, 'index'])->name('index');
        Route::get('/create', [Admin\SessionController::class, 'create'])->name('create');
        Route::post('/', [Admin\SessionController::class, 'store'])->name('store');
        Route::get('/{session}/edit', [Admin\SessionController::class, 'edit'])->name('edit');
        Route::put('/{session}', [Admin\SessionController::class, 'update'])->name('update');
        Route::delete('/{session}', [Admin\SessionController::class, 'destroy'])->name('destroy');
        Route::post('/bulk-destroy', [Admin\SessionController::class, 'destroyBulk'])->name('bulk-destroy');
    });
```

### 6.3 API JSON (nova, opcional)

```php
Route::apiResource('sessions', Api\SessionController::class);
```

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/sessions` | Lista paginada das sessões publicadas. |
| GET | `/api/sessions/{slug}` | Detalhe. |
| POST | `/api/sessions` | Cria (requer `sessoes.create`). |
| PUT/PATCH | `/api/sessions/{id}` | Atualiza. |
| DELETE | `/api/sessions/{id}` | Remove. |

---

**Continuação:** a API Resource, as views Blade modernas, a infraestrutura de
fila/cache/storage, o plano de testes e a lista de riscos da migração estão em
`09-API-VIEWS-E-INFRA.md`. O detalhamento dos casos de teste está em
`07-TESTES.md`.
