# Migração Moderna — Editor Estrutural + Landing Sections Editor

> Alvo: `public-v7` (Laravel 12, PHP 8.4, DDD-lite `core`/`services`/`layout`/`modules`).
> Caminhos legados relativos a `lotep/`; caminhos novos relativos a `src/`.
> Este documento permite escrever a migration e os contratos sem abrir o legado.

---

## 1. Decisão de desenho

O V2 guarda **HTML/Blade** em três lugares: conteúdo de áreas, snippets de assets e templates de
bloco. O V7 não pode repetir isso (ADR-016, ADR-017). A reconstrução separa:

| Legado | V7 |
|---|---|
| `v2_structural_portal_contents` (áreas com Blade) | `layout_slots` + widgets de código por região |
| `v2_layout_base_configs` (chave-valor JSONB) | `layout_configs` (JSONB validado) + `layout_slots` para a home |
| `v2_sle_assets` | serviço Media/Gallery + assets versionados no build (Vite) |
| `homepage_sections` v3 (lista) | `layout_slots` da página `portal.home`, ordenados por `position` |
| `BlockRenderer` (Blade do banco) | componentes Blade de código + props estruturadas |
| Schemas de validação por classe | um `LandingBlockRules` + Form Requests |
| Gate por `config('v2.support.email')` | Gate `layout.*` (ADR-033) + perfil `suporte` protegido |

**Não há** editor de código livre no V7. CSS/JS globais viram tema (tokens) e assets de build; HTML
livre vira AST de RichText (ADR-028) ou campo estruturado.

---

## 2. Schema de migrations

### 2.1 `layout_configs`

Configurações globais do shell (logos, contato, links, redes, LAI, flags de acessibilidade,
banner global).

| Coluna | Tipo | Regra |
|---|---|---|
| `id` | `uuid` | PK |
| `key` | `text` | UNIQUE; token `[a-z0-9_.-]+` |
| `value` | `jsonb` | NOT NULL DEFAULT `'{}'` |
| `created_at` / `updated_at` | `timestamptz` | — |

Sem `deleted_at`: histórico e restauração vêm da auditoria (ADR-035), não de soft delete — o soft
delete do legado colide com a UNIQUE de `key` em `firstOrNew`.

Migration ilustrativa (padrão ADR-017):

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::transaction(function (): void {
            DB::statement("SET LOCAL lock_timeout = '3s'");

            DB::statement(<<<'SQL'
                CREATE TABLE layout_configs (
                    id uuid PRIMARY KEY,
                    key text NOT NULL UNIQUE,
                    value jsonb NOT NULL DEFAULT '{}'::jsonb,
                    created_at timestamptz NOT NULL DEFAULT now(),
                    updated_at timestamptz NOT NULL DEFAULT now(),
                    CONSTRAINT layout_configs_key_token CHECK (key ~ '^[a-z0-9_.-]+$'),
                    CONSTRAINT layout_configs_value_object CHECK (jsonb_typeof(value) = 'object')
                )
            SQL);
        });
    }

    public function down(): void
    {
        DB::statement('ALTER TABLE layout_configs RENAME TO backup_layout_configs_' . date('Y_m_d_His'));
    }
};
```

### 2.2 `layout_slots`

Uma pilha ordenada por região. Atende header/footer/asides **e** a home.

| Coluna | Tipo | Regra |
|---|---|---|
| `id` | `uuid` | PK |
| `layout_key` | `text` | ex.: `portal.global`, `portal.home` |
| `region` | `text` | `header`, `main`, `footer`, `aside_start`, `aside_end`, `head`, `body_end` |
| `position` | `integer` | `>= 0` |
| `widget_type` | `text` | token registrado no catálogo |
| `props` | `jsonb` | parâmetros estruturados do widget |
| `hidden` | `boolean` | default `false` |
| `created_at` / `updated_at` | `timestamptz` | — |

Restrições:

```sql
CREATE TABLE layout_slots (
    id uuid PRIMARY KEY,
    layout_key text NOT NULL,
    region text NOT NULL,
    position integer NOT NULL,
    widget_type text NOT NULL,
    props jsonb NOT NULL DEFAULT '{}'::jsonb,
    hidden boolean NOT NULL DEFAULT false,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT layout_slots_region CHECK (region IN ('header','main','footer','aside_start','aside_end','head','body_end')),
    CONSTRAINT layout_slots_position CHECK (position >= 0)
);

ALTER TABLE layout_slots
  ADD CONSTRAINT layout_slots_order_uq
  UNIQUE (layout_key, region, position)
  DEFERRABLE INITIALLY DEFERRED;

CREATE INDEX layout_slots_lookup_idx
  ON layout_slots (layout_key, region, position)
  WHERE hidden = false;
```

O índice único é `DEFERRABLE` para permitir reordenar em transação (trocar posições sem violar a
constraint no meio do caminho).

### 2.3 Mídia

Não criar tabela. Usar `gallery_media` do serviço Gallery (ADR-026) e referenciar por `media_id`
**dentro de `props`**, validado na aplicação (`exists:gallery_media,id`) e resolvido no render pelo
serviço Media. Para blocos com muitas mídias (galeria/carrossel), `props.items[].media_id`.

### 2.4 Histórico

Não criar tabela de revisões. Usar a trilha nativa (ADR-035): cada `PUT` grava `before`/`after` no
`audit_log`, com `subject_type = layout_slots|layout_configs` e `subject_id`.

---

## 3. Modelos

Sem traits, abstract ou generics. Classes concretas e finais.

```php
<?php

declare(strict_types=1);

namespace Core\Layout\Models;

use Illuminate\Database\Eloquent\Model;

final class LayoutConfig extends Model
{
    protected $table = 'layout_configs';
    protected $keyType = 'string';
    public $incrementing = false;

    protected $fillable = ['id', 'key', 'value'];

    protected $casts = [
        'value' => 'array',
        'created_at' => 'immutable_datetime',
        'updated_at' => 'immutable_datetime',
    ];
}
```

```php
<?php

declare(strict_types=1);

namespace Layout\Portal\Models;

use Illuminate\Database\Eloquent\Model;

final class LayoutSlot extends Model
{
    protected $table = 'layout_slots';
    protected $keyType = 'string';
    public $incrementing = false;

    protected $fillable = [
        'id', 'layout_key', 'region', 'position', 'widget_type', 'props', 'hidden',
    ];

    protected $casts = [
        'props' => 'array',
        'hidden' => 'boolean',
        'position' => 'integer',
    ];
}
```

Enums:

```php
enum LayoutRegion: string
{
    case Header = 'header';
    case Main = 'main';
    case Footer = 'footer';
    case AsideStart = 'aside_start';
    case AsideEnd = 'aside_end';
    case Head = 'head';
    case BodyEnd = 'body_end';
}

enum LayoutAction: string
{
    case View = 'ver';
    case Edit = 'editar';
    case Approve = 'aprovar';
    case Archive = 'arquivar';
}
```

Não criar `Widget` como model: widget é **definição**, não linha de banco.

---

## 4. Catálogo de widgets (substitui `WidgetRegistry` estático)

`src/services/Landing/LandingWidgetCatalog.php` — instância registrada no container, sem estado
estático entre requests.

```php
<?php

declare(strict_types=1);

namespace Services\Landing;

final class WidgetDefinition
{
    /**
     * @param array<int, string> $regions
     */
    public function __construct(
        public readonly string $type,
        public readonly string $title,
        public readonly string $icon,
        public readonly array $regions,
        public readonly string $component,
        public readonly string $rulesClass,
    ) {
    }
}
```

```php
final class LandingWidgetCatalog
{
    /** @var array<string, WidgetDefinition> */
    private array $definitions = [];

    public function register(WidgetDefinition $definition): void
    {
        $this->definitions[$definition->type] = $definition;
    }

    public function get(string $type): ?WidgetDefinition
    {
        return $this->definitions[$type] ?? null;
    }

    /** @return array<string, WidgetDefinition> */
    public function all(): array
    {
        return $this->definitions;
    }
}
```

Registro: cada módulo declara seus widgets no `config()` do manifesto (ADR-014) e o Core instancia
o catálogo uma vez por request. O **serviço** não importa módulos; os módulos registram definições
com string de componente.

### 4.1 Mapa dos 35 tipos

| Origem V2 | V7 |
|---|---|
| 21 tipos legados (`hero`, `boxes`, `carousel`, `text-image`, `grid`, `buttons`, entre outros) | manter no catálogo, com componente `landing::blocks.{tipo}` |
| `html-free` | **rejeitar** |
| `bloco-sle` | **rejeitar** |
| `home-comunicado` | `home.comunicado` com `body` em AST RichText (ADR-028) |
| 9 seções da home | `home.hero`, `home.acesso-rapido`, `home.papel-regulatorio`, `home.destaques-cidadao`, `home-canais-atendimento`, `home.jogo-responsavel`, `home.comunicado`, `home.imprensa`, `home.dashboard`, `home.agenda`, `home.jogos-autorizados` |
| `home-header` | configuração do slot `header` (`layout_configs.portal.header`) |

---

## 5. Validação

Um único ponto, sem 31 classes:

```php
<?php

declare(strict_types=1);

namespace Services\Landing;

final class LandingBlockRules
{
    /**
     * @return array<string, string|array<int, string>>
     */
    public function for(string $type): array
    {
        return match ($type) {
            'home.hero' => [
                'props.eyebrow' => 'nullable|string|max:120',
                'props.title' => 'nullable|string|max:160',
                'props.lead' => 'nullable|string|max:400',
                'props.primary_cta.label' => 'nullable|string|max:60',
                'props.primary_cta.url' => 'nullable|url|max:255',
                'props.media_id' => 'nullable|uuid|exists:gallery_media,id',
            ],
            'home.acesso-rapido' => [
                'props.items' => 'array|max:12',
                'props.items.*.icon' => 'nullable|string|max:60',
                'props.items.*.title' => 'nullable|string|max:80',
                'props.items.*.description' => 'nullable|string|max:160',
                'props.items.*.url' => 'nullable|string|max:255',
            ],
            'home.comunicado' => [
                'props.body' => 'required|array',
            ],
            default => [
                'props' => 'array',
                'hidden' => 'boolean',
                'position' => 'integer|min:0',
            ],
        };
    }
}
```

Form Requests concretos:

| Request | Uso | Regras adicionais |
|---|---|---|
| `StoreLayoutSlotRequest` | criar bloco | `type` deve existir no catálogo; `region` deve ser suportada pelo tipo; `position` inteiro |
| `UpdateLayoutSlotRequest` | editar/ocultar | `props` validado por `LandingBlockRules` |
| `ReorderLayoutSlotsRequest` | reordenar | `slots` array de `{id, position}`; transação |
| `UpdateLayoutConfigRequest` | configs globais | validação por chave (ver §6) |

**Invariantes que não podem mudar:**

- Seções dinâmicas continuam marcadores de ordem; `props` delas é ignorado no render.
- `home.header` continua fora da ordem do `main`.
- Sem configuração, a ordem default é `ALL_TYPES` (9 itens).
- Remover = ocultar, não apagar histórico.

---

## 6. Policies e autorização

Gate por módulo/ação (ADR-033), nunca por e-mail:

```php
<?php

declare(strict_types=1);

namespace Modules\Support\StructuralLandingEditor\Access;

use Core\Access\Permission;
use Illuminate\Support\Facades\Gate;

final class LayoutGate
{
    public const MODULE = 'layout';

    public static function register(): void
    {
        Gate::define(self::MODULE . '.ver', fn ($user): bool => Permission::allows($user, self::MODULE, Permission::VIEW));
        Gate::define(self::MODULE . '.editar', fn ($user): bool => Permission::allows($user, self::MODULE, Permission::EDIT));
        Gate::define(self::MODULE . '.aprovar', fn ($user): bool => Permission::allows($user, self::MODULE, Permission::APPROVE));
    }
}
```

| Ação | Permissão | Efeito |
|---|---|---|
| abrir CMS e ver histórico | `layout.ver` | leitura de configs/slots |
| criar/editar/reordenar rascunho | `layout.editar` | grava `layout_slots` em rascunho |
| publicar na home/portal | `layout.aprovar` | promove rascunho a publicado (ADR-016) |
| arquivar bloco | `layout.arquivar` | `hidden = true` + auditoria |

O perfil `suporte` continua existindo, mas é **um perfil** (ADR-033), não um e-mail em `env`.

---

