# Configurações da Instituição — Migração para Laravel Moderno

> Alvo: PHP 8.x + Laravel 11/12. Referência de reconstrução da funcionalidade
> atual, sem alterar o comportamento observável do portal.

---

## 1. Schema de migração Laravel

### 1.1 Decisão estrutural

A tabela `configurations` genérica **não deve** ser recriada como está. O
recomendado é separar em tabelas de domínio + uma tabela `settings` genérica
para o resto:

```php
// database/migrations/2026_xx_xx_create_institution_settings_tables.php

Schema::create('institution', function (Blueprint $table) {
    $table->id();
    $table->string('structure_type')->default('prefeitura'); // prefeitura|camara
    $table->string('name')->nullable();                       // titulo_site
    $table->text('description')->nullable();                  // descricao
    $table->string('cnpj', 14)->nullable();
    $table->string('zip_code', 15)->nullable();                // cep
    $table->string('street')->nullable();                     // logradouro
    $table->string('number')->nullable();                     // numero
    $table->string('district')->nullable();                   // bairro
    $table->string('city')->nullable();                       // cidade
    $table->string('state', 2)->nullable();                   // estado
    $table->string('email')->nullable();                      // email
    $table->string('opening_hours')->nullable();              // expediente
    $table->string('mayor_name')->nullable();                 // nome_prefeito
    $table->timestamps();
});

Schema::create('institution_contacts', function (Blueprint $table) {
    $table->id();
    $table->string('email_lai')->nullable();
    $table->string('email_denuncia')->nullable();
    $table->string('email_ouvidoria')->nullable();
    $table->string('email_suporte')->nullable();
    $table->string('phone')->nullable();
    $table->string('phone_2')->nullable();
    $table->timestamps();
});

Schema::create('mail_settings', function (Blueprint $table) {
    $table->id();
    $table->string('driver')->default('smtp');
    $table->string('host')->nullable();
    $table->string('port')->nullable();
    $table->string('username')->nullable();
    $table->string('password')->nullable();   // cast 'encrypted'
    $table->string('encryption')->nullable();
    $table->string('from_address')->nullable();
    $table->string('from_name')->nullable();
    $table->timestamps();
});

Schema::create('recaptcha_settings', function (Blueprint $table) {
    $table->id();
    $table->string('public_key')->nullable();
    $table->string('private_key')->nullable(); // cast 'encrypted'
    $table->timestamps();
});

Schema::create('seo_settings', function (Blueprint $table) {
    $table->id();
    $table->text('keywords')->nullable();       // palavras_chaves
    $table->text('head_snippet')->nullable();   // incorporacao_head
    $table->text('footer_snippet')->nullable(); // incorporacao_rodape
    $table->timestamps();
});

Schema::create('portal_transparency_settings', function (Blueprint $table) {
    $table->id();
    $table->boolean('active')->default(false);
    $table->string('entity_code')->nullable();
    $table->string('public_url')->nullable();
    $table->timestamps();
});

Schema::create('institutional_badges', function (Blueprint $table) {
    $table->id();
    $table->string('image_path')->nullable();
    $table->string('original_name')->nullable();
    $table->string('title')->nullable();
    $table->string('url')->nullable();
    $table->enum('position', ['acima', 'abaixo', 'ambos']);
    $table->boolean('open_new_tab')->default(false);
    $table->string('description')->nullable();
    $table->timestamps();
});

Schema::create('settings', function (Blueprint $table) {
    $table->id();
    $table->string('key')->unique();      // índice único obrigatório
    $table->string('group')->index();     // substitui as faixas de `order`
    $table->text('value')->nullable();
    $table->string('type')->default('string'); // string|bool|int|json|file|secret
    $table->boolean('is_public')->default(false);
    $table->timestamps();
});
```

### 1.2 Migração dos dados

- `configurations` → distribuição por tabela conforme o mapa da seção 3.
- `order` numérico → coluna `group` com valores `institucional`, `seo`,
  `imagens`, `opcoes`, `apis`, `selos`, `email`, `dashboard`.
- `selos_institucionais` (JSON) → linhas em `institutional_badges`.
- `google_analytics_sa_json` → coluna `encrypted` em `settings` ou
  `dashboard_settings`.

## 2. Modelos

```php
// app/Models/Institution.php
class Institution extends Model
{
    use HasFactory;

    protected $fillable = [
        'structure_type','name','description','cnpj','zip_code','street','number',
        'district','city','state','email','opening_hours','mayor_name',
    ];

    protected function casts(): array
    {
        return ['structure_type' => InstitutionType::class];
    }
}

// app/Enums/InstitutionType.php
enum InstitutionType: string
{
    case Prefeitura = 'prefeitura';
    case Camara = 'camara';
}

// app/Models/Settings.php
class Setting extends Model
{
    protected $fillable = ['key','group','value','type','is_public'];

    protected function casts(): array
    {
        return [
            'value' => $this->castsValue(),
            'is_public' => 'boolean',
        ];
    }
}
```

| Model | Tabela | Casts |
|---|---|---|
| `Institution` | `institution` | `structure_type` → enum |
| `InstitutionContact` | `institution_contacts` | — |
| `MailSetting` | `mail_settings` | `password` → `encrypted` |
| `RecaptchaSetting` | `recaptcha_settings` | `private_key` → `encrypted` |
| `SeoSetting` | `seo_settings` | — |
| `PortalTransparencySetting` | `portal_transparency_settings` | `active` → `boolean` |
| `InstitutionalBadge` | `institutional_badges` | `open_new_tab` → `boolean` |
| `Setting` | `settings` | `value` dinâmico por `type` |
| `DashboardSetting` | `dashboard_settings` | `google_analytics_sa_json` → `encrypted` |

## 3. Mapa chave legada → destino

| Chave legada | Destino na v7 |
|---|---|
| `structure_type` | `institution.structure_type` (enum) |
| `titulo_site`, `descricao`, `cnpj`, `cep`, `logradouro`, `numero`, `bairro`, `cidade`, `estado`, `expediente`, `nome_prefeito` | `institution.*` |
| `email`, `email_lai`, `email_suporte`, `email_denuncia`, `email_ouvidoria`, `telefone`, `telefone_2` | `institution_contacts.*` |
| `palavras_chaves`, `incorporacao_head`, `incorporacao_rodape` | `seo_settings.*` |
| `logo`, `favicon`, `logo_rodape`, `aviso_noticias`, `imagem-acesso-rapido` | `settings` (tipo `file`) ou coluna própria |
| `codigo_tempo`, `licitacoes_url`, `response_time`, `recaptcha_public_key`, `recaptcha_private_key`, `link_form_artist_registration`, `link_form_equipment_registration`, `gemini_api_key` | `settings` |
| `noticias`, `newsletters`, `links_rapidos`, `contact_link`, `ultimas_noticias`, `menu_interativo`, `numero_versao`, `pagina_em_manutencao`, `app_env`, `app_debug`, `full_list_requests_disabled` | `settings` (tipo `bool`) — feature flags |
| `mail_*` | `mail_settings.*` |
| `portal_transparencia_*` | `portal_transparency_settings.*` |
| `selos_institucionais`, `selos_institucionais_url` | `institutional_badges.*` |
| `dashboard_*`, `google_analytics_*` | `dashboard_settings.*` |

## 4. Form Requests

Um request por aba, em vez de um request genérico com regras dinâmicas:

| Request | Campos |
|---|---|
| `UpdateInstitutionalSettingsRequest` | `structure_type` (enum), `name` (required, max 100, min 3), `description` (required, max 150), `cnpj` (nullable, size 14, digits), `zip_code` (required, max 15), `street`, `number`, `district`, `city`, `state` (required), `email` (required, email, max 50, min 3), `email_lai`, `email_suporte`, `email_denuncia`, `email_ouvidoria`, `phone`, `phone_2`, `opening_hours` (max 190), `mayor_name` (max 50, min 3). |
| `UpdateSeoSettingsRequest` | `keywords` (required, max 160), `head_snippet` (nullable, max 1000), `footer_snippet` (nullable, max 1000). |
| `UpdateBrandImagesRequest` | `logo` (image, max 255), `favicon` (image, max 255), `footer_logo`, `news_notice` (image, max 255), `quick_access_image`. |
| `UpdateFeatureFlagsRequest` | booleanos com `Rule::boolean()`. |
| `UpdateMailSettingsRequest` | `driver` (in: smtp,sendmail,mailgun,mandrill,ses,sparkpost,log,array), `host` (max 50), `port` (max 50), `username` (max 80), `password` (max 80), `encryption` (max 80), `from_address` (email, max 80), `from_name` (max 80). |
| `UpdateRecaptchaSettingsRequest` | `public_key` (max 150), `private_key` (max 150). |
| `UpdatePortalTransparencyRequest` | `active` (bool), `entity_code` (max 50), `public_url` (url, max 255). |
| `UpdateAnalyticsSettingsRequest` | `property_id` (digits), `measurement_id` (nullable), `service_account_json` (json), `refresh_minutes` (integer, min 1, max 1440), `widgets` (array), `widgets.*` (in: lista do `WidgetRegistry`). |
| `UpdateInstitutionalBadgesRequest` | `badges` (array), `badges.*.image` (image, required sem `image_path`), `badges.*.title` (required), `badges.*.url` (nullable, url), `badges.*.position` (in: acima,abaixo,ambos, required), `badges.*.description`. |

Regras que hoje são implícitas e devem virar explícitas: `notnull` → `required`,
`max_input` → `max`, `min_input` → `min`, `type == 'file'` → `image`.

## 5. Policies / autorização

```php
// app/Policies/InstitutionSettingsPolicy.php
class InstitutionSettingsPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->can('configuracoes.index');
    }

    public function update(User $user): bool
    {
        return $user->can('configuracoes.edit');
    }

    public function updateMail(User $user): bool
    {
        return $user->can('mail_recaptcha');
    }

    public function updateAnalytics(User $user): bool
    {
        return $user->can('dashboard-config.edit');
    }
}
```

Regras a preservar:

- Leitura da tela e gravação usam a **mesma** permissão `configuracoes.edit`
  (eliminar a assimetria atual).
- A aba Mail exige `mail_recaptcha`.
- GA/Dashboard exigem `dashboard-config.index` para exibir e
  `dashboard-config.edit` para gravar.
- O menu só aparece se o módulo `configuracoes-da-instituicao` estiver ativo
  (manter o gate por módulo, mas injetar `$modulos` em **todas** as views do
  CMS via um View Composer global).

## 6. Endpoints REST/JSON sugeridos

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/admin/institution` | Dados institucionais + contatos |
| PUT | `/api/v1/admin/institution` | Atualiza institucional |
| GET | `/api/v1/admin/settings/{group}` | Lê um grupo de settings |
| PUT | `/api/v1/admin/settings/{group}` | Atualiza um grupo (valida o group) |
| PUT | `/api/v1/admin/mail-settings` | Atualiza SMTP |
| PUT | `/api/v1/admin/recaptcha-settings` | Atualiza Recaptcha |
| PUT | `/api/v1/admin/analytics-settings` | Atualiza GA/Dashboard |
| GET | `/api/v1/admin/badges` | Lista selos |
| POST | `/api/v1/admin/badges` | Cria selo |
| PUT | `/api/v1/admin/badges/{badge}` | Atualiza selo |
| DELETE | `/api/v1/admin/badges/{badge}` | Remove selo |

## 7. API Resources

```php
// app/Http/Resources/InstitutionResource.php
class InstitutionResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'structure_type' => $this->structure_type->value,
            'name' => $this->name,
            'description' => $this->description,
            'address' => [
                'zip_code' => $this->zip_code,
                'street' => $this->street,
                'number' => $this->number,
                'district' => $this->district,
                'city' => $this->city,
                'state' => $this->state,
            ],
            'contacts' => new InstitutionContactResource($this->contacts),
        ];
    }
}
```

Endpoint público (consumido pelo portal) deve devolver apenas o que já é
público hoje: título, descrição, palavras-chave, logo, favicon, endereço,
expediente, e-mail, telefones, nome do prefeito e os flags de seção.

## 8. Views Blade modernas

- Um componente `<x-settings-panel>` com abas geradas a partir do `group`
  (sem faixas numéricas).
- Um componente `<x-settings-field>` que resolve o input pelo `type`
  (`string`, `text`, `bool`, `int`, `file`, `json`, `secret`) — substitui
  `loop_config.blade.php`.
- Manter o mesmo layout AdminLTE/`<x-admin-layout>` e os mesmos nomes de rota
 visual (Institucional, SEO, Imagens, Opções, Portal de Transparência Público,
  Selos, Mail, Dashboard).
- Preservar o retorno à mesma aba: `redirect()->back()->with('display', ...)`
  ou, melhor, incluir a aba na URL (`?tab=opcoes`).

## 9. Fila, cache e storage

| Item | v6 | v7 |
|---|---|---|
| Cache do módulo | `Cache::rememberForever('modulos')` | idem, mas com o composer global de `$modulos` |
| Cache da transparência | `Cache::remember('portal_transparencia_config', 600, ...)` | idem, invalidado por evento de modelo |
| Cache de estrutura | `Cache::pull('structureType')` | evento `InstitutionSaved` → `Cache::forget('structureType')` |
| Segredos | `Crypt::encrypt` manual | cast `encrypted` do Laravel 11 |
| Arquivos de marca | `storage/app/public/configuracoes` | idem, com `Storage::disk('public')` |
| Selos | disco `public`, pasta `selos/` | idem |
| `config.json` | gerado em disco | **eliminar**: `.env` para o runtime + tabela `mail_settings`/`recaptcha_settings` com cache |
| Redimensionamento | Intervention estático | `ImageManager` injetado, fila opcional |

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

| # | Risco | Decisão |
|---|---|---|
| M1 | Remover `config.json` muda o comportamento de boot | Migrar os valores para `.env`/tabela e garantir que a primeira gravação na tela não quebre o app (gravar em cache, não em arquivo de config). |
| M2 | Trocar faixas de `order` por `group` reordena a tela | Validar com teste de paridade da ordem das abas. |
| M3 | `required` derivado de `notnull` muda a validação | Mapear explicitamente no Form Request; chaves com `notnull` NULL viram opcionais. |
| M4 | Selos passam de JSON para tabela | Preservar `posicao` obrigatória e a ordem de exibição; a listagem pública `GET /selos` deve continuar idêntica. |
| M5 | Segredos agora cifrados com cast | A chave `google_analytics_sa_json` nunca deve ser reexibida; manter o texto “Credencial configurada.” |
| M6 | Permissões migradas de Sentinel | Mapear `configuracoes.index`, `configuracoes.edit`, `mail_recaptcha`, `dashboard-config.index`, `dashboard-config.edit` 1:1. |
| M7 | `structure_type` hoje só oferece 2 valores no select, mas o módulo de telas aceita outros perfis | Manter os valores observados (`prefeitura`, `camara`) e documentar os demais. |
| M8 | `pagina_em_manutencao` afeta apenas não logados | Preservar exatamente esse comportamento. |
| M9 | `noticias`/`ultimas_noticias`/`newsletters` são lidos por helper global | Substituir por uma classe de feature flags injetada, sem alterar a semântica (0 = desabilitado). |
| M10 | `gemini_api_key` fica em tabela de settings | Preservar o fallback `env('GEMINI_API_KEY')` do `DataMaskTrait`. |
