# A Cidade — Plano de Migração para Laravel Moderno

> Alvo: PHP 8.3 / Laravel 12 / PostgreSQL 16.
> Este documento é um **plano**, não uma implementação.

---

## 1. Decisão de enquadramento: módulo ou tipo de conteúdo?

A Cidade, na v6, é uma **categoria de conteúdo** — não um domínio. Duas
estratégias possíveis:

| Opção | Descrição | Custo | Risco |
|---|---|---|---|
| **A. Tipo de conteúdo configurável** | `contents` ganha `content_type_id` e o módulo vira um *perfil* declarativo (template + campos). | Médio | Baixo — preserva URLs e dados. |
| B. Tabela própria `city_pages` | Tabela dedicada com FK para `contents`. | Alto | Alto — duplica o motor de galeria/vídeo e migra dados. |

**Recomendação: A.** O conteúdo institucional do município é uma variação do
conteúdo institucional genérico; criar uma tabela própria reintroduz exatamente
o que a v6 já evita (replicação de código). O que muda na v7 é que o "tipo"
passa a ser **dado de configuração**, não `if ($slug === 'a-cidade')` espalhado
pelo código.

## 2. Schema de migração

### 2.1 Tabela de tipos de conteúdo (nova)

```php
// (ilustrativo) database/migrations/2026_01_01_000000_create_content_types_table.php
Schema::create('content_types', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();          // 'a-cidade'
    $table->string('name');                     // 'A Cidade'
    $table->string('route_prefix')->unique();   // 'a-cidade'
    $table->string('view_component');           // 'city-page'
    $table->jsonb('allowed_extensions')->nullable();
    $table->boolean('status')->default(true);
    $table->timestamps();
});
```

### 2.2 Alterações em `contents`

```php
// (ilustrativo)
Schema::table('contents', function (Blueprint $table) {
    $table->foreignId('content_type_id')->nullable()->constrained('content_types')->nullOnDelete();
    $table->timestampTz('published_at')->nullable();
    $table->unsignedBigInteger('view_count')->default(0);
    $table->unique(['content_type_id', 'slug']);
});
```

| Coluna nova | Origem na v6 | Observação |
|---|---|---|
| `content_type_id` | `category_contents_id` | Substitui a FK para `category_contents` **para os módulos migrados**. |
| `published_at` | `publication_date` | Separar "data de publicação" de "data de criação". |
| `view_count` | `clicks` | Contador atômico (`increment()`). |

### 2.3 Migração de dados

```sql
-- (ilustrativo) scripts de backfill
INSERT INTO content_types (slug, name, route_prefix, view_component, status, created_at, updated_at)
SELECT 'a-cidade', 'A Cidade', 'a-cidade', 'city-page', true, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM content_types WHERE slug = 'a-cidade');

UPDATE contents SET content_type_id = (SELECT id FROM content_types WHERE slug = 'a-cidade')
    WHERE category_contents_id = 4;

UPDATE contents SET view_count = clicks WHERE clicks IS NOT NULL;
```

Ordem obrigatória: (1) criar `content_types` populado a partir de
`category_contents`; (2) criar `contents.content_type_id` **sem** índice único;
(3) backfill; (4) resolver duplicatas de slug (manter a mais antiga, renomear as
demais com sufixo `-2`, `-3`, ...); (5) só então criar o índice único
`['content_type_id','slug']`; (6) por último remover
`contents.category_contents_id`, depois de migrar os demais módulos.

> O passo 4 é obrigatório: hoje `contents.slug` **não tem** unicidade e a
> colisão é resolvida em runtime com o sufixo `-<max(id)+1>`
> (`ContentController.php:665-669`). Backfill sem backfill de duplicatas falha.

### 2.4 Índices recomendados

Substituem o `row_number() OVER (ORDER BY c.created_at DESC)` da view SQL:
`CREATE INDEX contents_type_published_idx ON contents (content_type_id, published_at DESC) WHERE deleted_at IS NULL;` e
`CREATE INDEX contents_type_created_idx ON contents (content_type_id, created_at DESC) WHERE deleted_at IS NULL;`

### 2.5 A view `a_cidade` — destino

A view é **eliminada**. A listagem do CMS passa a ser uma query do Eloquent:
`Content::query()->whereBelongsTo($type)->whereNull('deleted_at')->latest('created_at')->paginate();`

Se ainda se quiser o número da linha (`value`), usar `RowNumber` do PostgreSQL
12+ ou apenas o índice da paginação — o número não é identificador.

## 3. Modelos

### 3.1 `ContentType`

`app/Models/ContentType.php` — `final class ContentType extends Model`, com
`HasFactory`; `$fillable = ['slug','name','route_prefix','view_component',
'allowed_extensions','status']`; `casts()` convertendo `allowed_extensions` em
`array` e `status` em `boolean`; e a relação `contents(): HasMany` para
`Content`.

### 3.2 `Content` (ajustes)

```php
// (ilustrativo) app/Models/Content.php
final class Content extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['title', 'slug', 'text', 'published_at', 'view_count',
                           'highlights', 'galery', 'status', 'content_type_id'];

    protected function casts(): array
    {
        return ['published_at' => 'datetime', 'galery' => 'boolean', 'highlights' => 'boolean',
                'status' => 'boolean', 'view_count' => 'integer'];
    }

    public function contentType(): BelongsTo
    {
        return $this->belongsTo(ContentType::class);
    }

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

    public function gallery(): HasMany
    {
        return $this->hasMany(Archive::class)->whereNull('highlight')
            ->where('extension', '!=', 'pdf')->orderByDesc('created_at');
    }

    public function video(): HasOne
    {
        return $this->hasOne(Link::class);
    }

    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', true)
            ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
    }
}
```

Ganhos sobre a v6: slug único por tipo (índice único
`['content_type_id','slug']`, hoje não garantido); filtro de publicação
(scope `published()`, hoje inexistente); contador de cliques atômico
(`increment('view_count')`, hoje read-modify-write em 2 queries); galeria sem
PDF no escopo; relação do vídeo com nome não ambíguo (`video`).

### 3.3 Relations de nome estável

Renomear `linksUnique` → `video` e `archiveHighlight`/`archiveGallery` →
`highlight`/`gallery` exige um *script* de busca e substituição em todas as
views. Alternativa de menor risco: manter aliases temporários
(`public function linksUnique(): HasOne { return $this->video(); }` e
idênticos para as outras duas).

## 4. Rotas

```php
// (ilustrativo) routes/web.php
Route::get('/a-cidade/{slug}', CityPageController::class)
     ->where('slug', '[a-z0-9\-]+')
     ->name('a-cidade.index');
```

Ou, melhor, uma rota **genérica** movida pelo `route_prefix` do tipo:
`Route::get('/{type}/{slug}', ContentPageController::class)->whereIn('type', ContentType::query()->pluck('route_prefix'))->name('conteudo.pagina');`

Decisões: `GET` em vez de `ANY` (o módulo não processa POST); restrição de
charset no `{slug}` (evita que `?` e `/` entrem na busca); `firstOrFail()` (404
real em vez de 200 com página de erro); **manter o prefixo `/a-cidade`** — não
pode mudar, as URLs estão indexadas e o formulário do CMS mostra o "Link
Permanente" ao operador.

## 5. Controller

```php
// (ilustrativo) app/Http/Controllers/Portal/CityPageController.php
final class CityPageController extends Controller
{
    public function __invoke(Request $request, Content $content): View
    {
        $this->authorize('view', $content);

        $content->load(['highlight', 'gallery', 'contentType']);

        $video = $content->video ? VideoEmbed::from($content->video->link) : null;

        $content->increment('view_count');

        return view('portal.city-page', compact('content', 'video'));
    }
}
```

Diferenças em relação à v6: busca manual por `contents.slug` → route model
binding; ausência de filtro de categoria → `authorize('view')` +
`scopePublished()`; erro 404 devolvido como 200 → `firstOrFail()`;
`getTemplateActived()` com manutenção automática → layout resolvido por view
composer / `config('portal.layout')`; `embedVideo()` global → `VideoEmbed`
value object; `updateClicks()` com 2 queries → `increment()`; variáveis
`$contentNew`, `$title`, `$linkVideo` → variáveis nomeadas e tipadas.

## 6. Policies e autorização

```php
// (ilustrativo) app/Policies/ContentPolicy.php
final class ContentPolicy
{
    public function view(?User $user, Content $content): bool
    {
        if (! $content->content_type?->status) {
            return false;
        }

        return $content->status
            && ($content->published_at === null || $content->published_at->lte(now()));
    }
}
```

A regra de publicação substitui o par `contents.status` + `contents.publication_date`
da v6 e resolve de uma vez os bugs BUG-1 e BUG-2 registrados em
`08-GAPS-E-DIVIDAS.md`.

O `check.route.permission` (que hoje deduz a permissão do nome da rota e da
posição do segmento da URL) é **substituído** por Policies + Gates: a permissão
passa a estar associada ao *tipo de conteúdo*, e não ao módulo — o que elimina
a dualidade `modulos.status` × `has_access()` descrita em
`03-ROTAS-E-CONTROLADORES.md` §8.2.

Para o CMS, o mesmo arquivo recebe os métodos `create(User, ContentType)`,
`update(User, Content)` e `delete(User, Content)`, delegando para
`$user->can('content.create|update|delete', $content->contentType)`.

## 7. Form Requests

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

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'text' => ['required', 'string'],
            'slug' => ['nullable', 'string', 'max:200', 'regex:/^[a-z0-9\-]+$/'],
            'link' => ['nullable', 'url', 'max:2999'],
            'archive_highlight' => ['nullable', 'image', 'max:20000'],
            'gallery' => ['nullable', 'boolean'],
            'published_at' => ['nullable', 'date'],
        ];
    }
}
```

As regras são escolhidas pelo **tipo de conteúdo** (tabela de regras ou classe
por tipo), e não por `switch ($slug)`.

Ganhos: `link` passa a ser validado como **URL** (hoje qualquer texto até 2999
caracteres é aceito); `slug` passa a ter formato validado (hoje só há `max:200`
no `case 'noticias'`); `authorize()` deixa de devolver `true`
incondicionalmente.

## 8. Recursos de API

O módulo **não** tem API na v6. Para a v7, o conteúdo institucional é dado
aberto natural (geografia, história, símbolos):

```php
// (ilustrativo) routes/api.php
Route::get('/v1/a-cidade', CityPageController::class)->name('api.a-cidade.index');
Route::get('/v1/a-cidade/{slug}', CityPageShowController::class)->name('api.a-cidade.show');
```

Recursos: `CityPageResource` (`id`, `slug`, `title`, `text_html`, `highlight`,
`gallery`, `video`, `published_at`, `links`), `ArchiveResource` (`id`, `url`,
`mime`, `size`, `caption`) e `VideoResource` (`provider`, `embed_url`,
`original_url`).

```json
{
  "data": [
    {
      "id": 1, "slug": "historia", "title": "História",
      "text_html": "<p>...</p>",
      "highlight": { "url": "https://...", "alt": "..." },
      "gallery": [{ "url": "https://...", "caption": "..." }],
      "video": { "provider": "youtube", "embed_url": "https://www.youtube.com/embed/..." },
      "published_at": "2026-01-01T00:00:00Z",
      "links": { "self": "https://.../api/v1/a-cidade/historia" }
    }
  ]
}
```

## 9. Views Blade

### 9.1 Um componente, não seis

A view da v7 é um único componente Blade
(`resources/views/portal/content/show.blade.php`) que compõe os slots
`heading`, `media`, `rich-text`, `gallery` e `video` — exatamente os slots que a
v6 reproduzia em 6 arquivos (ver `04-VIEWS-E-UX.md` §2.1):

```blade
{{-- (ilustrativo) --}}
<x-portal.page :content="$content">
    <x-portal.content.heading :title="$content->title" />
    <x-portal.content.media :highlight="$content->highlight" />
    <x-portal.content.rich-text :html="$content->textHtml" />
    <x-portal.content.gallery :items="$content->gallery" />
    <x-portal.content.video :embed="$video?->embedUrl" />
</x-portal.page>
```

O layout (`modelo1`..`modelo6`) passa a ser um **slot** do componente, ou uma
classe de tema (CSS) — não uma cópia da view. Elimina-se a replicação
documentada em `04-VIEWS-E-UX.md` §9.

### 9.2 Galeria e compartilhamento

A galeria deixa de ser lightGallery replicado em 6 arquivos e vira o componente
único `<x-portal.gallery>`; o PDF deixa de ser renderizado como imagem (filtro
`extension != 'pdf'` no escopo do model); e `getMinFile()`/`getLargFile()` —
hoje uma convenção de nome de arquivo — viram accessors `Archive->url` e
`Archive->thumbnailUrl`. O bloco `addtoany` (5 botões) vira
`<x-share-links :url="$url" />` com o mesmo markup — a diferença v5 → v6
(`addthis` → `addtoany`) fica registrada em uma única linha de configuração.

## 10. Fila, cache e storage

| Aspecto | Proposta na v7 |
|---|---|
| **Fila** | Processamento de imagem (derivados webp/min/larg) e envio de e-mail de notificação passam para jobs. O `store()` hoje move arquivos **síncronamente** dentro da transação. |
| **Cache** | `Cache::remember('content-types.active', 3600, ...)` para a lista de tipos; cache de página por `slug` com invalidação no `saved`/`deleted` do model. O `view_count` **não** deve ser cacheado (é write-through). |
| **Storage** | Disco `s3` (ou MinIO) para `content/{tipo}/{id}/destaque` e `.../galeria`. Manter a chave de caminho v6 (`storage/content/a-cidade/<id>/...`) para preservar os links já publicados. |
| **Redis e métricas** | Redis já disponível na stack (cache de view, filas). `view_count` na própria tabela + evento `ContentViewed` para analytics assíncrono. |

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

| # | Risco / decisão | Regra |
|---|---|---|
| R-1 | **URL pública** | `/a-cidade/{slug}` deve continuar funcionando. Não alterar o prefixo. |
| R-2 | **Slug do conteúdo** | Não pode mudar: o "Link Permanente" já foi comunicado a terceiros. Migração de slug exige plano de redirect 301. |
| R-3 | **Página de erro** | Hoje devolve 200 com "CONTEÚDO NÃO ENCONTRADO". Na v7 passa a ser 404 — **melhoria**, mas muda o comportamento observável (SEO, métricas). Registrar como mudança consciente. |
| R-4 | **Filtros de status e categoria** | Rascunhos passam a ser ocultados e um slug de outra categoria deixa de ser servido em `/a-cidade/{slug}`. Correções de segurança que **mudam o comportamento observável** — exigem aviso ao negócio. |
| R-6 | **Cliques** | `contents.clicks` → `contents.view_count`. Manter a semântica (1 incremento por requisição) ou migrar para sessão única? **Decisão de negócio** — o recomendado é sessão única + contador assíncrono. |
| R-7 | **Ordem da listagem** | `ORDER BY created_at DESC` — manter. O `row_number()` pode ser descartado. |
| R-8 | **Galeria de PDF** | PDFs passam a não aparecer como imagem. Correção, mas muda o visual. |
| R-9 | **Imagem de destaque** | Passa a aparecer (o `file_exist()` relativo nunca funcionava). **Melhoria visível** — validar com o negócio. |
| R-10 | **Vídeo** | Suporte a YouTube, Vimeo, Facebook e Instagram deve ser mantido; os *fallbacks* hoje silenciosos devem passar a gerar um aviso ao operador. |
| R-11 | **Permissões** | `a-cidade.index/create/edit/delete/restore/inactivate` → `content.*` por tipo de conteúdo. Migrar as regras do papel `administrador` automaticamente. |
| R-12 | **Menu do CMS** | `modulos.status = false` ocultava A Cidade. Na v7 a visibilidade passa a depender **apenas** da Policy. Decisão de negócio: o módulo volta a aparecer? |
| R-13 | **Exportação e SEO** | Excel e PDF no cliente serão substituídos por export no servidor (queue), com o mesmo layout de colunas. A rota `ANY` vira `GET` e URLs com query string passam a redirecionar para a forma canônica. |

## 12. Ordem de execução sugerida

1. Criar `content_types` + backfill (sem breaking change).
2. Criar `contents.content_type_id`, `published_at`, `view_count` + backfill.
3. Resolver slugs duplicados e criar o índice único.
4. Introduzir `ContentType` + `Content` (novos modelos em `app/Models/`) em
   paralelo aos legados.
5. Nova rota + `CityPageController` + `ContentPolicy`, atrás de *feature flag*.
6. Componente Blade único `<x-portal.page>` e migração das telas do CMS para o
   `ContentController` novo (Form Requests por tipo).
7. Migrar as permissões para Policies e remover `check.route.permission`.
8. Desligar o `AboutController` legado, remover a view SQL `a_cidade` e, **após**
   todos os módulos de conteúdo, `category_contents` e
   `contents.category_contents_id`.

### 12.1 Critérios de aceite da migração

Nenhuma URL pública quebrada (regressão sobre a lista de slugs publicados);
nenhum rascunho acessível (teste T-05 em `07-TESTES.md`); nenhum conteúdo de
outra categoria acessível (teste T-04); slugs preservados (comparar
`contents.slug` antes/depois do backfill); links de arquivo preservados
(conferir `archives.path` apontando para arquivos existentes).
