# Carta de Serviços — Migração para Laravel Moderno

> Alvo: PHP 8.3 + Laravel 11/12, PostgreSQL 15+, Blade com Alpine.js ou Livewire, queue com Redis.

## 1. Princípios de equivalência funcional (o que NÃO pode mudar)

| # | Regra | Por quê |
|---|---|---|
| E1 | Um serviço só é público quando `ativo && publicado` | É o contrato com o cidadão |
| E2 | `publicado_em` marca a **primeira** publicação | Traz a data real de disponibilização |
| E3 | A ficha do serviço exibe **todas** as seções: sobre, requisitos, documentos, etapas, custos, prazos, prioridades, horário, endereço, canais, responsáveis, informações adicionais | É o conteúdo legal da Carta de Serviços |
| E4 | Etapas appear em ordem de `numero` | O número é o que o cidadão lê ("etapa 1 de 3") |
| E5 | O canal exibe apenas o contato correspondente ao seu `tipo` | Evita expor dados de outro canal |
| E6 | Responsável exige nome + cargo **ou** e-mail **ou** telefone | Regra de identificação mínima |
| E7 | A listagem pública é paginada (12 por página) | Performance e usabilidade |
| E8 | A busca é acento-insensitiva | UX pt-BR |
| E9 | O PDF consolidado contém **todos** os serviços publicados | É o "livro" da Carta de Serviços |
| E10 | Incrementar o contador de acessos a cada visita à ficha | Métrica de interesse do citizen |

## 2. Schema de migração Laravel 11/12

### 2.1 Tabelas (equivalência direta)

```php
// database/migrations/2026_xx_xx_create_service_letters_tables.php
Schema::create('service_letters', function (Blueprint $table) {
    $table->id();
    $table->foreignId('secretaria_id')->nullable()->constrained('contents')->nullOnDelete();
    $table->string('nome');
    $table->string('slug')->unique();
    $table->string('descricao_curta', 500)->nullable();
    $table->enum('menu_acesso_rapido', ['cidadao', 'empresa', 'servidor'])->default('cidadao');
    $table->enum('tipo_exibicao', ['pagina', 'link_externo'])->default('pagina');
    $table->string('link_externo', 500)->nullable();
    $table->boolean('acesso_online')->default(false);
    $table->boolean('acesso_telefone')->default(false);
    $table->boolean('acesso_presencial')->default(false);
    $table->jsonb('sobre')->nullable();
    $table->jsonb('documentacoes')->nullable();
    $table->jsonb('custos')->nullable();
    $table->jsonb('etapas')->nullable();
    $table->jsonb('requisitos')->nullable();
    $table->jsonb('justificativa')->nullable();
    $table->jsonb('prioridades_atendimento')->nullable();
    $table->jsonb('informacoes_adicionais')->nullable();
    $table->boolean('gratuito')->default(true);
    $table->decimal('valor_taxa', 10, 2)->nullable();
    $table->string('previsao_atendimento')->nullable();
    $table->text('previsao_atendimento_detalhes')->nullable();
    $table->string('horario_atendimento')->nullable();
    $table->text('horario_atendimento_detalhes')->nullable();
    $table->text('endereco_atendimento')->nullable();
    $table->string('endereco_cep', 10)->nullable();
    $table->decimal('endereco_latitude', 10, 8)->nullable();
    $table->decimal('endereco_longitude', 11, 8)->nullable();
    $table->string('responsavel_nome')->nullable();
    $table->string('responsavel_cargo')->nullable();
    $table->string('responsavel_foto', 500)->nullable();
    $table->string('responsavel_email')->nullable();
    $table->string('responsavel_telefone', 20)->nullable();
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->boolean('publicado')->default(false);
    $table->timestamp('publicado_em')->nullable();
    $table->boolean('destaque')->default(false);
    $table->unsignedBigInteger('visualizacoes')->default(0);
    $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamps();
    $table->softDeletes();
    $table->index(['ativo', 'publicado']);
    $table->index('secretaria_id');
});
```

**Mudanças em relação ao legado, e por quê:**

| Mudança | Motivo |
|---|---|
| `enum` no lugar de `varchar` + validação | O domínio já é fechado e validado no controller; mover para o banco elimina a classe inteira de dados inválidos |
| `jsonb` no lugar de `text` para HTML do Summernote | Legibilidade, possibilidade de extrair texto puro para busca/indexação e segurança contra HTML arbitrário |
| `foreignId` (bigint) | Coerência de tipos — elimina a divergência `bigint`/`integer` do pivot |
| Colunas `created_by`/`updated_by` como `foreignId` | Consistência com o resto do schema moderno |
| Índice composto `(ativo, publicado)` | A consulta do portal filtra exatamente por esses dois campos |
| `title`/`slug` com `unique` preservado | — |

### 2.2 Demais tabelas

```php
Schema::create('service_letter_subjects', function (Blueprint $table) {
    $table->id();
    $table->string('nome');
    $table->string('slug')->unique();
    $table->text('descricao')->nullable();
    $table->enum('tipo_visual', ['icone', 'imagem'])->default('icone');
    $table->string('icone', 100)->nullable();
    $table->string('icone_cor', 7)->default('#000000');
    $table->string('imagem', 500)->nullable();
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('service_letters_subjects_rel', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_letter_id')->constrained()->cascadeOnDelete();
    $table->foreignId('subject_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
    $table->unique(['service_letter_id', 'subject_id']);
});

Schema::create('service_letter_channels', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_letter_id')->constrained()->cascadeOnDelete();
    $table->enum('tipo', ['online', 'telefone', 'presencial']);
    $table->string('nome')->nullable();
    $table->text('descricao')->nullable();
    $table->string('url', 500)->nullable();
    $table->string('telefone', 20)->nullable();
    $table->string('endereco')->nullable();
    $table->string('horario_funcionamento')->nullable();
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('service_letter_steps', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_letter_id')->constrained()->cascadeOnDelete();
    $table->unsignedSmallInteger('numero');
    $table->string('titulo');
    $table->text('descricao')->nullable();
    $table->string('prazo', 100)->nullable();
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->timestamps();
    $table->softDeletes();
    // ★ Elimina a ambiguidade legado numero/ordem:
    $table->unique(['service_letter_id', 'numero']);
});

Schema::create('service_letter_documents', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_letter_id')->constrained()->cascadeOnDelete();
    $table->string('nome');
    $table->text('descricao')->nullable();
    $table->boolean('obrigatorio')->default(true);
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('service_letter_attachments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_letter_id')->constrained()->cascadeOnDelete();
    $table->string('nome');
    $table->string('descricao', 500)->nullable();
    $table->string('arquivo', 500);
    $table->string('tipo_arquivo', 100)->nullable();
    $table->unsignedBigInteger('tamanho_bytes')->nullable();
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('service_letter_responsaveis', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_letter_id')->nullable()->constrained()->cascadeOnDelete();
    $table->string('nome')->nullable();
    $table->string('cargo')->nullable();
    $table->string('email')->nullable();
    $table->string('telefone', 20)->nullable();
    $table->string('foto', 500)->nullable();
    $table->unsignedSmallInteger('ordem')->default(0);
    $table->boolean('ativo')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('service_letter_configs', function (Blueprint $table) {
    $table->id();
    $table->string('nome')->default('Carta de Serviços');
    $table->text('descricao')->nullable();
    $table->jsonb('cabecalho')->nullable();
    $table->jsonb('rodape')->nullable();
    $table->jsonb('texto_antes_sumario')->nullable();
    $table->jsonb('texto_depois_sumario')->nullable();
    $table->jsonb('texto_encerramento')->nullable();
    $table->boolean('exibir_sumario')->default(true);
    $table->enum('tipo_capa', ['editor', 'pdf'])->default('editor');
    $table->jsonb('capa_conteudo_editor')->nullable();
    $table->string('capa_arquivo_pdf', 500)->nullable();
    $table->enum('ordenacao_servicos', ['secretaria', 'assunto', 'nome'])->default('secretaria');
    $table->string('fonte_texto', 100)->default('Arial');
    $table->unsignedSmallInteger('tamanho_fonte_texto')->default(12);
    $table->string('cor_texto', 7)->default('#000000');
    $table->string('fonte_titulo', 100)->default('Arial');
    $table->unsignedSmallInteger('tamanho_fonte_titulo')->default(16);
    $table->string('cor_titulo', 7)->default('#000000');
    $table->boolean('ativo')->default(true);
    $table->boolean('publicado')->default(false);
    $table->timestamp('publicado_em')->nullable();
    $table->timestamps();
});

Schema::create('service_letter_pdfs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('config_id')->constrained()->cascadeOnDelete();
    $table->string('arquivo', 500);
    $table->string('nome_arquivo');
    $table->unsignedBigInteger('tamanho_bytes')->nullable();
    $table->unsignedInteger('total_servicos')->default(0);
    $table->unsignedInteger('total_paginas')->nullable();
    $table->string('ordenacao_utilizada', 20)->nullable();
    $table->boolean('incluiu_sumario')->nullable();
    $table->enum('status', ['gerado', 'publicado', 'erro'])->default('gerado');
    $table->text('erro_mensagem')->nullable();
    $table->timestamp('gerado_em')->useCurrent();
    $table->foreignId('gerado_por')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamp('publicado_em')->nullable();
    $table->foreignId('publicado_por')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamps();
    $table->softDeletes();
});
```

**Decisões relevantes:**

| Decisão | Detalhe |
|---|---|
| **Eliminar `service_letter_access_logs`** | A tabela nunca foi gravada. Substituir por um contador denormalizado (`visualizacoes`) ou, se o negócio quiser série histórica, criar `service_letter_access_daily` com `service_letter_id`, `date`, `count` — gravada por um job assíncrono. **Requer decisão do negócio.** |
| **Unicidade de `service_letter_configs`** | Adicionar uma coluna `key` com valor fixo (`'principal'`) e `unique`, resolvendo o problema do singleton sem constraint |
| **`unique(['service_letter_id', 'numero'])` em etapas** | Resolve a divergência `numero` vs `ordem`: passa a existir **uma** ordem canônica |
| **Remover `etapas` (HTML) ou `steps` (estruturado)** | **Decisão de produto pendente.** Recomenda-se manter apenas `steps` (estruturado) e derivar o texto livre por concatenação; ou manter apenas o HTML. Manter as duas é a causa da incoerência de conteúdo hoje |
| **Remover os campos `responsavel_*` de `service_letters`** | Duplicam `service_letter_responsaveis`. Manter apenas a tabela filha |
| **JSONB para o conteúdo rico** | Se a decisão for manter Summernote/WYSIWYG, `jsonb` + sanitização no servidor é o caminho moderno |

## 3. Models

```php
// app/Models/ServiceLetter.php
enum MenuAcessoRapido: string { case Cidadao = 'cidadao'; case Empresa = 'empresa'; case Servidor = 'servidor'; }
enum TipoExibicao: string { case Pagina = 'pagina'; case LinkExterno = 'link_externo'; }

class ServiceLetter extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['nome', 'slug', 'descricao_curta', 'secretaria_id',
        'menu_acesso_rapido', 'tipo_exibicao', 'link_externo',
        'acesso_online', 'acesso_telefone', 'acesso_presencial',
        'sobre', 'documentacoes', 'custos', 'gratuito', 'valor_taxa',
        'etapas', 'requisitos', 'justificativa', 'previsao_atendimento',
        'previsao_atendimento_detalhes', 'prioridades_atendimento',
        'horario_atendimento', 'horario_atendimento_detalhes',
        'endereco_atendimento', 'endereco_cep', 'endereco_latitude', 'endereco_longitude',
        'responsavel_nome', 'responsavel_cargo', 'responsavel_foto',
        'responsavel_email', 'responsavel_telefone', 'informacoes_adicionais',
        'ordem', 'ativo', 'publicado', 'publicado_em', 'destaque',
        'created_by', 'updated_by'];

    protected function casts(): array
    {
        return [
            'menu_acesso_rapido' => MenuAcessoRapido::class,
            'tipo_exibicao' => TipoExibicao::class,
            'acesso_online' => 'boolean', 'acesso_telefone' => 'boolean',
            'acesso_presencial' => 'boolean', 'gratuito' => 'boolean',
            'ativo' => 'boolean', 'publicado' => 'boolean', 'destaque' => 'boolean',
            'publicado_em' => 'datetime',
            'valor_taxa' => 'decimal:2',
            'endereco_latitude' => 'decimal:8', 'endereco_longitude' => 'decimal:8',
            'sobre' => 'array', 'documentacoes' => 'array', 'custos' => 'array',
            'etapas' => 'array', 'requisitos' => 'array', 'justificativa' => 'array',
            'prioridades_atendimento' => 'array', 'informacoes_adicionais' => 'array',
        ];
    }

    public function subjects(): BelongsToMany
    {
        return $this->belongsToMany(ServiceLetterSubject::class, 'service_letters_subjects_rel')
            ->withTimestamps();
    }

    public function secretaria(): BelongsTo { return $this->belongsTo(Content::class, 'secretaria_id'); }
    public function channels(): HasMany { return $this->hasMany(ServiceLetterChannel::class)->orderBy('ordem'); }
    public function steps(): HasMany { return $this->hasMany(ServiceLetterStep::class)->orderBy('numero'); }
    public function documents(): HasMany { return $this->hasMany(ServiceLetterDocument::class)->orderBy('ordem'); }
    public function attachments(): HasMany { return $this->hasMany(ServiceLetterAttachment::class)->orderBy('ordem'); }
    public function responsaveis(): HasMany { return $this->hasMany(ServiceLetterResponsavel::class)->orderBy('ordem'); }

    /** Escopo reutilizável — elimina a duplicação de "ativo && publicado". */
    public function scopePublicados(Builder $q): Builder
    {
        return $q->where('ativo', true)->where('publicado', true);
    }

    /** Escopo de busca acento-insensitivo, sem SQL específico de engine. */
    public function scopeBuscar(Builder $q, ?string $termo): Builder
    {
        if (blank($termo)) return $q;
        $termo = Str::ascii($termo);
        return $q->where(fn ($sub) => $sub
            ->whereRaw('unaccent(lower(nome)) ILIKE ?', ["%{$termo}%"])
            ->orWhereRaw('unaccent(lower(descricao_curta)) ILIKE ?', ["%{$termo}%"]));
    }
}
```

| Model | Mudanças |
|---|---|
| `ServiceLetterSubject` | `tipo_visual` como enum; `belongsToMany` com `withTimestamps()` |
| `ServiceLetterChannel` | `tipo` como enum `ChannelType`; método `contato()` retornando o campo correspondente ao `tipo` (elimina a lógica repetida nos 3 templates do portal) |
| `ServiceLetterStep` | ordenação por `numero` na relação |
| `ServiceLetterConfig` | `key` único; casts para os campos JSONB |
| `ServiceLetterPdf` | `status` como enum `PdfStatus`; **remover** `$timestamps = false` (a tabela tem as colunas) |
| `ServiceLetterAccessLog` | **remover** (ver §2.2) ou corrigir o `$table` para `service_letter_access_logs` e gravar de fato |

## 4. Form Requests

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

    public function rules(): array
    {
        return [
            'nome' => ['required', 'string', 'max:255'],
            'slug' => ['nullable', 'string', 'max:255', Rule::unique('service_letters')],
            'secretaria_id' => ['required', Rule::exists('contents', 'id')],
            'menu_acesso_rapido' => ['required', Rule::enum(MenuAcessoRapido::class)],
            'tipo_exibicao' => ['required', Rule::enum(TipoExibicao::class)],
            'link_externo' => ['nullable', 'url', 'max:500', 'required_if:tipo_exibicao,link_externo'],
            'sobre' => ['nullable', 'array'],
            'valor_taxa' => ['nullable', 'numeric', 'min:0'],
            'acesso_online' => ['boolean'], 'acesso_telefone' => ['boolean'],
            'acesso_presencial' => ['boolean'], 'gratuito' => ['boolean'],
            'ativo' => ['boolean'], 'publicado' => ['boolean'], 'destaque' => ['boolean'],
            'subjects' => ['nullable', 'array'], 'subjects.*' => [Rule::exists('service_letter_subjects', 'id')],
            'responsavel_foto' => ['nullable', 'image', 'max:2048'],
        ];
    }

    public function messages(): array
    {
        return [
            'link_externo.required_if' => 'Informe a URL do serviço externo.',
            'secretaria_id.required' => 'Selecione a secretaria responsável.',
        ];
    }
}
```

| Request | Regras específicas |
|---|---|
| `StoreServiceLetterRequest` / `UpdateServiceLetterRequest` | ver acima; `UpdateServiceLetterRequest` ignora `slug` na unicidade ou usa `Rule::unique()->ignore($this->route('id'))` |
| `StoreSubjectRequest` | `nome` required, `slug` unique, `tipo_visual` enum, `icone` required_if `tipo_visual=icone`, `imagem` required_if `tipo_visual=imagem` + `image\|max:2048` |
| `StoreChannelRequest` | `tipo` enum, `url` required_if `tipo=online` + `url`, `telefone` required_if `tipo=telefone`, `endereco` required_if `tipo=presencial` |
| `StoreStepRequest` | `numero` required integer min:1, `titulo` required, `unique` por serviço |
| `StoreDocumentRequest` | `nome` required |
| `StoreAttachmentRequest` | `arquivo` required file max:10240, mimes conforme política |
| `StoreResponsavelRequest` | regra cruzada: `required_without_all:email,telefone` para `nome`/`cargo` e `required_without_all:nome,cargo,email` para `telefone` — **expressa diretamente em regras do Laravel**, sem PHP customizado |
| `UpdateConfigRequest` | `tipo_capa` enum, `ordenacao_servicos` enum, `capa_arquivo_pdf` nullable file mimes:pdf max:20480 |

> A regra cruzada de responsável, hoje implementada em PHP (`validateResponsavel`), cabe em `required_without_all`. Isso elimina o `Validator::make` manual e o 422 vs 500 inconsistente.

