# TV Câmara — Plano de Migração para Laravel Moderno

> **Alvo:** PHP 8.x + Laravel 11/12 (a "v7").
> **Origem:** módulo `tv-camara` da `v6` (Laravel 5.5 / PostgreSQL 10).

---

## 1. Princípios de equivalência

Antes de desenhar o código, o que **não pode mudar** (contrato funcional com a
Câmara e com o cidadão):

| # | Regra a preservar | Origem na v6 |
|---|---|---|
| E1 | No máximo **3** vídeos na home, ordenados por data de publicação decrescente. | `v6/app/Http/Controllers/Portal/PortalController.php:161-163` |
| E2 | Listagem pública com `status = true`, `publication_date <= now()`, `deleted_at IS NULL`, ordem por data desc e **12 itens por página**. | `v6/app/Http/Controllers/Portal/TvCamaraController.php:23-30` |
| E3 | Player embutido em iframe 16:9 com a URL de embed do provedor (YouTube, Vimeo, Facebook, Instagram). | `v6/app/Helpers/functions.php:607-665` + `v6/resources/views/portal/statics/video.blade.php` |
| E4 | Imagem de destaque (card e home) com fallback para miniatura do YouTube/Vimeo e para `sem-imagem.png`. | `v6/app/Http/Presenters/ContentPresenter.php:89-129` |
| E5 | Exclusão por soft delete. | `v6/app/Http/Model/Prefecture/TvCamara.php:9` |
| E6 | "Exibir na página inicial" continua sendo o interruptor de exibição do vídeo no portal. | `v6/resources/views/prefecture/contents/tv-camara/form.blade.php:75-79` |
| E7 | Slug gerado a partir do título e URL pública `/tv-camara/{slug}`. | `v6/app/Http/Controllers/Prefecture/TvCamaraController.php:47` |

O que **pode** mudar (e deve): nomenclatura de colunas, uso de `is_active`/
`published_at`, existência de `provider`/`video_id`, validação por provedor,
Policy, API JSON, testes automatizados, `Sanctum`/`Passport` para o portal.

## 2. Schema de migração Laravel

```php
// database/migrations/2025_XX_XX_create_tv_camaras_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('tv_camaras', function (Blueprint $table) {
            $table->id();
            $table->string('title', 120);
            $table->text('text')->nullable();
            $table->string('slug', 191)->unique();
            $table->string('video_url', 500);          // URL original cadastrada
            $table->string('provider', 20)->nullable(); // youtube | vimeo | facebook | instagram | other
            $table->string('video_id', 64)->nullable();  // id normalizado por provedor
            $table->string('embed_url', 500)->nullable(); // URL de embed persistida
            $table->boolean('is_active')->default(false);
            $table->timestamp('published_at')->nullable();
            $table->string('thumbnail_path')->nullable();
            $table->foreignId('highlight_archive_id')->nullable()
                  ->constrained('archives')->nullOnDelete();
            $table->timestamps();
            $table->softDeletes();

            $table->index(['is_active', 'published_at']);
            $table->index('provider');
        });
    }

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

### 2.1 Migração de dados (v6 → v7)

```php
DB::table('tv_camaras')->orderBy('id')->chunk(200, function ($rows) {
    foreach ($rows as $row) {
        $parsed = app(VideoLinkParser::class)->parse($row->link);

        DB::table('tv_camaras')->where('id', $row->id)->update([
            'video_url'         => $row->link,
            'provider'          => $parsed->provider,
            'video_id'          => $parsed->id,
            'embed_url'         => $parsed->embedUrl,
            'is_active'         => (bool) $row->status,
            'published_at'      => $row->publication_date,
            'slug'              => $row->slug,
            'created_at'        => $row->created_at,
            'updated_at'        => $row->updated_at,
            'deleted_at'        => $row->deleted_at,
        ]);
    }
});
```

Pontos de atenção da migração:

| Tema | Tratamento |
|---|---|
| Slugs duplicados | A coluna vira `unique`; antes de aplicar, resolver duplicatas (sufixo `-2`, `-3`...) e mapear a URL antiga por um redirect. |
| `status = null` | Migrar para `is_active = false`. |
| `publication_date` com default literal | Migrar sempre o valor real da linha (a coluna nova não deve ter default fixo; usar `published_at` sem default ou `now()` apenas no controller). |
| `archives.tv_camara_id` | Manter a coluna e a FK, ou migrar para `highlight_archive_id` + manter `tv_camara_id` para compatibilidade com a galeria. |
| Arquivos físicos | Copiar `storage/tv-camara/` para o novo disco (`storage/app/public/tv-camara/`) ou reconfigurar o volume do `public`. |
| Links antigos | O `slug` não muda; a URL pública permanece `/tv-camara/{slug}`. |

## 3. Modelos

```php
// app/Models/TvCamara.php
namespace App\Models;

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

class TvCamara extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'text', 'slug', 'video_url', 'provider',
        'video_id', 'embed_url', 'is_active', 'published_at', 'thumbnail_path',
    ];

    protected $casts = [
        'is_active'   => 'boolean',
        'published_at'=> 'datetime',
    ];

    protected $attributes = [
        'is_active' => false,
    ];

    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    public function highlight(): HasOne
    {
        return $this->hasOne(Archive::class, 'tv_camara_id')->where('highlight', true);
    }

    public function scopePublished(Builder $query): Builder
    {
        return $query->where('is_active', true)
                     ->whereNotNull('published_at')
                     ->where('published_at', '<=', now());
    }

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

### 3.1 Serviço de normalização do link (novo)

Substitui `embedVideo()` por uma classe testável:

```php
// app/Services/Video/VideoLinkParser.php   (exemplo ilustrativo)
final class VideoLinkParser
{
    public function parse(string $url): ParsedVideoLink
    {
        $url = trim($url);
        if ($url === '') {
            throw new InvalidVideoLink('Informe o link do vídeo.');
        }

        // Compatibilidade com o ID cru do YouTube (formatos aceitos pela v6 no placeholder do formulário)
        if (preg_match('/^[A-Za-z0-9_-]{11}$/', $url)) {
            $id = $url;
            return new ParsedVideoLink('youtube', $id, "https://www.youtube.com/embed/{$id}");
        }

        $host = strtolower((string) parse_url($url, PHP_URL_HOST));
        $id   = $this->extractId($host, $url);

        return match (true) {
            str_contains($host, 'youtube.com'), str_contains($host, 'youtu.be')
                => new ParsedVideoLink('youtube', $id, "https://www.youtube.com/embed/{$id}"),
            str_contains($host, 'vimeo.com')
                => new ParsedVideoLink('vimeo', $id, "https://player.vimeo.com/video/{$id}"),
            str_contains($host, 'facebook.com')
                => new ParsedVideoLink('facebook', $id, $url),
            str_contains($host, 'instagram.com')
                => new ParsedVideoLink('instagram', $id, $this->instagramEmbed($url)),
            default => throw new InvalidVideoLink('Provedor de vídeo não suportado.'),
        };
    }
}
```

> O ID cru passa a ser aceito de fato (o que a v6 sugeria mas não fazia) — ganho de
> usabilidade, sem quebrar a equivalência do player.

## 4. Form Requests

```php
// app/Http/Requests/StoreTvCamaraRequest.php   (exemplo ilustrativo)
class StoreTvCamaraRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', TvCamara::class);
    }

    public function rules(): array
    {
        return [
            'title'           => ['required', 'string', 'max:120'],
            'text'            => ['nullable', 'string', 'max:20000'],
            'publication_date'=> ['required', 'date_format:d/m/Y H:i'],
            'link'            => ['required', 'string', 'max:500', 'url'],
            'is_active'       => ['sometimes', 'boolean'],
            'archive_highlight'=> ['nullable', 'image', 'max:5120'],
        ];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            try {
                app(VideoLinkParser::class)->parse((string) $this->input('link'));
            } catch (InvalidVideoLink $e) {
                $validator->errors()->add('link', $e->getMessage());
            }
        });
    }
}
```

Mudanças em relação à v6:

| Item | v6 | v7 |
|---|---|---|
| `link` | `required|max:200` (sem validar URL) | `required|url` + validação por provedor |
| `publication_date` | `required|max:200` (texto livre) | `date_format:d/m/Y H:i` |
| `text` | sem regra | `nullable|string|max` |
| `archive_highlight` | sem regra | `image|max:5120` (5 MB, como a UI avisa) |
| Autorização | `authorize() = true` | Policy `create`/`update`/`delete` |

## 5. Policies

```php
// app/Policies/TvCamaraPolicy.php   (exemplo ilustrativo)
class TvCamaraPolicy
{
    public function viewAny(User $user): bool  { return $user->can('tv-camara.index'); }
    public function create(User $user): bool   { return $user->can('tv-camara.create'); }
    public function update(User $user, TvCamara $tv): bool { return $user->can('tv-camara.edit'); }
    public function delete(User $user, TvCamara $tv): bool { return $user->can('tv-camara.delete'); }
    public function restore(User $user, TvCamara $tv): bool { return $user->can('tv-camara.restore'); }
}
```

As permissões passam a ser **uma permission por ação** (`Gate::define` ou
`spatie/laravel-permission`), e não a matriz `recurso.ação` montada na view
`admin/role/form.blade.php` da v6. Isso elimina de raiz os bugs de slug
(`tv-camaras.delete`, `tv_camara.edit`).

## 6. Rotas

```php
// routes/web.php
Route::middleware(['check.site.manutention'])->group(function () {
    Route::get('/tv-camara', [PublicTvCamaraController::class, 'index'])->name('portal.tv-camara.index');
    Route::get('/tv-camara/{tvCamara:slug}', [PublicTvCamaraController::class, 'show'])->name('portal.tv-camara.show');
});

// routes/cms.php  (prefixo /cms, middleware auth + permissões)
Route::resource('tv-camara', TvCamaraController::class)->names([
    'index'   => 'tv-camara.index',
    'create'  => 'tv-camara.create',
    'store'   => 'tv-camara.store',
    'edit'    => 'tv-camara.edit',
    'update'  => 'tv-camara.update',
    'destroy' => 'tv-camara.destroy',
]);
Route::patch('tv-camara/{tvCamara}/status', [TvCamaraController::class, 'setStatus'])->name('tv-camara.setStatus');
Route::post('tv-camara/bulk-destroy', [TvCamaraController::class, 'destroyBulk'])->name('tv-camara.destroyBulk');

// routes/api.php
Route::apiResource('tv-camara', TvCamaraApiController::class)
    ->parameters(['tv-camara' => 'tvCamara']);
Route::get('tv-camara-home', TvCamaraApiController::class . '@home')->name('api.tv-camara.home');
```

Ganhos: rota de edição com nome coerente (`tv-camara.edit`), `PATCH` para
status, exclusão em lote explícita e model binding por `slug` no portal.

## 7. API Resource

```php
// app/Http/Resources/TvCamaraResource.php   (exemplo ilustrativo)
class TvCamaraResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'          => $this->id,
            'title'       => $this->title,
            'text'        => $this->text,
            'slug'        => $this->slug,
            'url'         => $this->video_url,
            'provider'    => $this->provider,
            'video_id'    => $this->video_id,
            'embed_url'   => $this->embed_url,
            'is_active'   => $this->is_active,
            'published_at'=> $this->published_at?->toIso8601String(),
            'thumbnail'   => $this->thumbnail_path ? Storage::url($this->thumbnail_path) : null,
            'links'       => [
                'public'  => route('portal.tv-camara.show', $this->slug),
            ],
        ];
    }
}
```

## 8. Views e componentes Blade

| Legado (v6) | Moderno (v7) |
|---|---|
| 6 cópias de `portal/layouts/modeloN/tv-camara/{index,show,index-home}.blade.php` | Componente único `<x-tv-camara.card>`, `<x-tv-camara.player>`, `<x-tv-camara.home-block>` usado por todos os temas. |
| `portal/statics/video.blade.php` | Componente `<x-video-embed :src="$embedUrl" />` (responsivo, com `title`/`allow` acessível). |
| `prefecture/contents/tv-camara/{index,create,edit,form}.blade.php` | `resources/views/cms/tv-camara/{index,create,edit}.blade.php` + `<x-cms.form-field>`, `<x-cms.image-upload>`. |
| Summernote | Editorrich-text modern (TipTap / Quill / Lexical) com **sanitização** server-side. |

Regras de acessibilidade a incorporar: `alt` na imagem, `title` no `iframe`,
`loading="lazy"` nos iframes, `aria-label` nos botões de ação.

## 9. Storage, cache e fila

| Item | Plano |
|---|---|
| Imagem de destaque | `storage/app/public/tv-camara/{id}/destaque/{nome}` + `php artisan storage:link`; gravação com `Storage::putFileAs` (sem `storeAs` relativo). |
| Miniaturas do YouTube/Vimeo | Cacheadas com `Cache::remember('tv.thumb.{videoId}', 1 day, ...)` para não depender da rede a cada render. |
| Fila | `GenerateVideoThumbnail` (Job) após o upload, para extrair/gerar a miniatura quando o provedor não fornecer uma. |
| Cache do bloco da home | `Cache::remember('tv-camara.home', 300, ...)` invalidado por evento de modelo (`saved`/`deleted`). |
| Logs | `activity()`/auditoria em cada `store`, `update`, `destroy` e `setStatus`. |

## 10. Testes automatizados

| Tipo | Cobertura |
|---|---|
| Unit | `VideoLinkParser`: YouTube (URL e ID cru), Vimeo, Facebook, Instagram, URL inválida, URL sem provedor. |
| Feature (portal) | Listagem filtra por `is_active` e `published_at`; paginação de 12; detalhe por slug; 404 para slug inexistente; bloco da home com no máximo 3 itens. |
| Feature (CMS) | Autorização (Policy), validação do Form Request, `store` cria registro + archive de destaque, `update` substitui o arquivo, `destroy` faz soft delete, `setStatus` alterna, exclusão em lote. |
| Feature (API) | `GET /api/tv-camara` paginado e filtrável; resource com os campos esperados. |

Detalhamento dos casos em `07-TESTES.md`.

## 11. Riscos de equivalência

| Risco | Mitigação |
|---|---|
| URLs antigas com slug duplicado | Índice único + redirects por mapa `slug_antigo -> slug_novo`. |
| Mudança na ordem da listagem do CMS | Manter `created_at DESC` na listagem administrativa ou documentar a mudança para a operação. |
| Perda do rótulo "Exibir na página inicial" | Traduzir para "Publicado no portal" e documentar a mudança de semântica. |
| Troca do player de iframe | Manter o mesmo formato de URL de embed para não quebrar links externos/embeds do portal. |
| Migração de `archives` compartilhada | A galeria é usada por conteúdos, estáticas, agenda e TV Câmara: a coluna `tv_camara_id` precisa continuar válida. |
| Permissões | Recriar a matriz de `resources`/`permissions` no novo modelo de Gates para não perder o histórico de perfis. |
