# Ouvidoria — Migração para Laravel Moderno

> Alvo: PHP 8.3 + Laravel 11/12, PostgreSQL 15+, queue com Redis, Sanctum ou Passport para a API.

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

| # | Regra | Por quê |
|---|---|---|
| E1 | Todo chamado nasce com `status_id = 1` (Em Tramitação) | Ponto de partida do fluxo |
| E2 | Responder muda o status para **2**; indeferir muda para **3**; prorrogar muda para **4** | Contrato com o cidadão e com o relatório |
| E3 | O **protocolo** é único, no formato `OUV-{dmY-His}` | É a chave de consulta do cidadão |
| E4 | O relatório estatístico mantém os 8 indicadores (4 status × no prazo / após o prazo) e o limite de **20 dias** | Pubblicação institucional |
| E5 | As exportações (xls, csv, pdf, txt) usam **apenas** dados mascarados | LGPD |
| E6 | O mascaramento (`mask_description`, `mask_answer`, `masked_protocol`, `masked_name`) é mantido e aplicado **no momento da gravação** | Princípio da proteção desde a origem |
| E7 | A contestação pelo token continua disponível, com **duas** vagas | Direito do cidadão |
| E8 | O e-mail ao setor responsável usa o `email` do **assunto** | Regra de roteamento |
| E9 | O histórico de tramitação (setor/assunto de origem e destino) é preservado | Auditoria |
| E10 | Os cadastros de apoio (assuntos, setores, origens, tipos, formas de resposta, respostas prontas) continuam sendo mantidos pelo CMS | São a base do formulário |

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

```php
// database/migrations/2026_xx_xx_create_ombudsman_tables.php
Schema::create('ombudsman_manifestation_types', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->boolean('status')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('ombudsman_sectors', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->foreignId('content_id')->nullable()->constrained('contents')->nullOnDelete();
    $table->boolean('active')->default(true);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('ombudsman_subjects', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->foreignId('sector_id')->nullable()->constrained('ombudsman_sectors')->restrictOnDelete();
    $table->foreignId('secretary_id')->nullable()->constrained('contents')->nullOnDelete();
    $table->string('email');
    $table->string('responsible');
    $table->timestamps();
    $table->softDeletes();
    $table->index('sector_id');
});

Schema::create('ombudsman_origins', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('ombudsman_response_methods', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('ombudsman_canned_responses', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('message');
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('ombudsman_requests', function (Blueprint $table) {
    $table->id();
    $table->foreignId('type_id')->constrained('ombudsman_manifestation_types')->cascadeOnDelete();
    $table->foreignId('subject_id')->constrained('ombudsman_subjects')->cascadeOnDelete();
    $table->foreignId('response_method_id')->constrained('ombudsman_response_methods')->cascadeOnDelete();
    $table->foreignId('origin_id')->constrained('ombudsman_origins')->cascadeOnDelete();
    $table->foreignId('status_id')->constrained('statuses');
    $table->string('protocol')->unique();
    $table->enum('modality', ['identificado', 'sigiloso', 'anonimo'])->default('identificado');
    $table->boolean('anonymous')->default(false);
    $table->string('name')->nullable();
    $table->string('email')->nullable();
    $table->string('phone')->nullable();
    $table->text('description');
    $table->text('mask_description')->nullable();
    $table->string('number')->nullable();
    $table->string('neighborhood')->nullable();
    $table->string('complement')->nullable();
    $table->string('address')->nullable();
    $table->string('reference_point')->nullable();
    $table->string('user_option')->nullable();
    $table->ipAddress('ip')->nullable();
    $table->string('browser')->nullable();
    $table->string('token')->nullable();          // hash do token de contestação
    $table->text('answer_contestation')->nullable();
    $table->timestamp('answer_contestation_date')->nullable();
    $table->text('answer_contestation_2')->nullable();
    $table->timestamp('answer_contestation_2_date')->nullable();
    $table->timestamps();
    $table->softDeletes();
    $table->index(['status_id', 'created_at']);
    $table->index('modality');
});

Schema::create('ombudsman_answers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('ombudsman_request_id')->constrained()->cascadeOnDelete();
    $table->text('answer');
    $table->text('mask_answer')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('ombudsman_answer_archives', function (Blueprint $table) {
    $table->id();
    $table->foreignId('ombudsman_answer_id')->constrained('ombudsman_answers')->cascadeOnDelete();
    $table->foreignId('archive_id')->constrained('archives')->cascadeOnDelete();
    $table->timestamps();
    $table->unique(['ombudsman_answer_id', 'archive_id']);
});

Schema::create('ombudsman_request_logs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('ombudsman_request_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
    $table->foreignId('sector_from_id')->nullable()->constrained('ombudsman_sectors')->nullOnDelete();
    $table->foreignId('sector_to_id')->nullable()->constrained('ombudsman_sectors')->nullOnDelete();
    $table->foreignId('subject_from_id')->nullable()->constrained('ombudsman_subjects')->nullOnDelete();
    $table->foreignId('subject_to_id')->nullable()->constrained('ombudsman_subjects')->nullOnDelete();
    $table->enum('type', ['forward', 'status_change', 'closed', 'reopened', 'comment']);
    $table->text('observation')->nullable();
    $table->timestamps();
    $table->index('ombudsman_request_id');
});
```

### 2.1 Decisões de schema

| Decisão | Motivo |
|---|---|
| **Recriar `ombudsman_answer_archives`** | Restaura a funcionalidade de anexos (quebrada na v6) |
| **`status_id` como `foreignId`** → id BIGINT | O legado usa `integer`; alinhar com `users`/`contents` (bigint) |
| **`modality` como `enum`** | Elimina os valores inválidos (hoje o controller valida `integer` contra strings) |
| **Tratar `ip` como `ipAddress()`** | Tipo semântico; valores inválidos viram `null` em vez de erro |
| **`token` como hash (`Hash::make`)** | Mantém a semântica; idem |
| **FK de `ombudsman_request_logs.sector_*` → `ombudsman_sectors`** | Corrige a divergência model↔banco (hoje aponta para `contents`) |
| **Índice `(status_id, created_at)`** | Suporta o relatório estatístico |
| **Manter `secretary_id` em `ombudsman_subjects`** | Ainda é usado como fallback (`sector_name`) e pela view |
| **Remover a coluna `archives_id` de `ombudsman_answers`** | Substituída pelo pivô N:N |
| **Manter `statuses` como tabela compartilhada** | Compatibilidade com os demais módulos; ou migrar para `enum` **apenas** neste módulo (não recomendado — quebraria o Dashboard) |

### 2.2 Dados a migrar

| Origem | Destino | Observação |
|---|---|---|
| `ombudsman_requests` | idem | Recalcular `mask_description` para registros antigos sem máscara |
| `ombudsman_answers` | idem | Recalcular `mask_answer` |
| `ombudsman_answer_archives` (v5) | idem | Se a base v6 não tiver a tabela, buscar o dado na v5 |
| `ombudsman_request_logs.sector_from_id`/`sector_to_id` | idem | Migrar de `contents.id` para `ombudsman_sectors.id` (mapear por `content_id`) |
| `relatorios_estatisticos_ouvidoria` (v5) | **nova tabela `ombudsman_reports`** | Ver §8 e `09-MAPA-V5-V6.md` |

## 3. Models

```php
// app/Enums/OmbudsmanStatus.php
enum OmbudsmanStatus: int
{
    case EmTramitacao = 1;
    case Atendido     = 2;
    case Indeferido   = 3;
    case Prorrogado   = 4;

    public function label(): string
    {
        return match ($this) {
            self::EmTramitacao => 'Em Tramitação',
            self::Atendido     => 'Atendido',
            self::Indeferido   => 'Indeferido',
            self::Prorrogado   => 'Prorrogado',
        };
    }

    public function cor(): string
    {
        return match ($this) {
            self::EmTramitacao => '#FDC02F',
            self::Atendido     => '#30A64A',
            self::Indeferido   => '#DA3849',
            self::Prorrogado   => '#25A2B7',
        };
    }
}

// app/Enums/OmbudsmanModality.php
enum OmbudsmanModality: string
{
    case Identificado = 'identificado';
    case Sigiloso     = 'sigiloso';
    case Anonimo      = 'anonimo';
}
```

```php
// app/Models/OmbudsmanRequest.php
class OmbudsmanRequest extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['type_id', 'subject_id', 'response_method_id', 'origin_id',
        'status_id', 'protocol', 'modality', 'anonymous', 'name', 'email', 'phone',
        'description', 'mask_description', 'number', 'neighborhood', 'complement',
        'address', 'reference_point', 'user_option', 'ip', 'browser', 'token',
        'answer_contestation', 'answer_contestation_date',
        'answer_contestation_2', 'answer_contestation_2_date'];

    protected function casts(): array
    {
        return [
            'modality' => OmbudsmanModality::class,
            'status_id' => OmbudsmanStatus::class,
            'anonymous' => 'boolean',
            'mask_description' => 'array',   // se migrado para jsonb
            'answer_contestation' => 'array',
            'answer_contestation_date' => 'datetime',
            'answer_contestation_2' => 'array',
            'answer_contestation_2_date' => 'datetime',
        ];
    }

    public function type(): BelongsTo { return $this->belongsTo(OmbudsmanManifestationType::class, 'type_id'); }
    public function subject(): BelongsTo { return $this->belongsTo(OmbudsmanSubject::class, 'subject_id'); }
    public function responseMethod(): BelongsTo { return $this->belongsTo(OmbudsmanResponseMethod::class, 'response_method_id'); }
    public function origin(): BelongsTo { return $this->belongsTo(OmbudsmanOrigin::class, 'origin_id'); }
    public function status(): BelongsTo { return $this->belongsTo(Status::class, 'status_id'); }
    public function answers(): HasMany { return $this->hasMany(OmbudsmanAnswer::class); }
    public function logs(): HasMany { return $this->hasMany(OmbudsmanRequestLog::class)->latest(); }

    /** Escopo de prazo — substitui os escopos SQL com EXTRACT/whereRaw. */
    public function scopePrazo(Builder $q, int $dias = 20): Builder
    {
        return $q->whereRaw('EXTRACT(DAY FROM now() - created_at) <= ?', [$dias]);
    }
}
```

| Model | Mudanças |
|---|---|
| `OmbudsmanAnswer` | **Restaurar** `archives(): BelongsToMany` com `->withTimestamps()`; `ombudsmanRequests()` → `belongsTo(OmbudsmanRequest::class, 'ombudsman_request_id')` |
| `OmbudsmanSubject` | `ombudsmans()` → `hasMany(OmbudsmanRequest::class)`; manter accessor `sector_name` |
| `OmbudsmanSector` | manter; `subjects()` → `hasMany(OmbudsmanSubject::class, 'sector_id')` |
| `OmbudsmanOrigin` | **remover** `description` do `$fillable` (a coluna não existe) ou criar a coluna |
| `OmbudsmanResponseMethod` | `ombudsmans()` → `hasMany(OmbudsmanRequest::class)` |
| `OmbudsmanManifestationType` | `ombudsmans()` → `hasMany(OmbudsmanRequest::class)` |
| `OmbudsmanCannedResponse` | manter; corrigir o `use` da relação |
| `OmbudsmanRequestLog` (novo) | `OmbudsmanRequestLogs` → `OmbudsmanRequestLog`; corrigir `sectorFrom`/`sectorTo` |

## 4. Form Requests

```php
// app/Http/Requests/Ombudsman/StorePublicRequest.php
class StorePublicRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'modality' => ['required', Rule::enum(OmbudsmanModality::class)],
            'type_id' => ['required', Rule::exists('ombudsman_manifestation_types', 'id')],
            'subject_id' => ['required', Rule::exists('ombudsman_subjects', 'id')],
            'response_method_id' => ['required', Rule::exists('ombudsman_response_methods', 'id')],
            'origin_id' => ['required', Rule::exists('ombudsman_origins', 'id')],
            'name' => ['required_unless:modality,anonimo', 'nullable', 'string', 'max:255'],
            'email' => ['required_unless:modality,anonimo', 'nullable', 'email', 'max:255'],
            'phone' => ['required_unless:modality,anonimo', 'nullable', 'string', 'max:20'],
            'description' => ['required', 'string', 'max:10000'],
            'number' => ['nullable', 'string', 'max:20'],
            'neighborhood' => ['nullable', 'string', 'max:255'],
            'complement' => ['nullable', 'string', 'max:255'],
            'address' => ['nullable', 'string', 'max:255'],
            'reference_point' => ['nullable', 'string', 'max:255'],
            'user_option' => ['nullable', 'string', 'max:255'],
            'token' => ['required', 'string', 'min:6', 'max:100'],
            'g-recaptcha-response' => ['required', 'captcha'],   // ← reativar
        ];
    }

    public function messages(): array
    {
        return [
            'name.required_unless' => 'Informe seu nome, salvo se a modalidade for anônima.',
            'email.required_unless' => 'Informe seu e-mail, salvo se a modalidade for anônima.',
            'phone.required_unless' => 'Informe seu telefone, salvo se a modalidade for anônima.',
            'token.required' => 'Defina um token para poder contestar a resposta.',
        ];
    }

    /** Regra condicional no servidor: anônimo não envia dados pessoais. */
    public function withValidator(Validator $v): void
    {
        $v->after(function (Validator $v) {
            if ($this->input('modality') === OmbudsmanModality::Anonimo->value) {
                foreach (['name', 'email', 'phone', 'address', 'neighborhood',
                          'number', 'reference_point', 'complement'] as $field) {
                    if ($this->filled($field)) {
                        $v->errors()->add($field, 'Campo não permitido em manifestação anônima.');
                    }
                }
            }
        });
    }
}
```

| Request | Regras |
|---|---|
| `StorePublicRequest` | ver acima — **corrige** a divergência `integer` × string e **reativa** o captcha |
| `StoreAnswerRequest` | `ombudsman_request_id` required\|exists; `type` required\|in:0,1; `answer` required_if:type,1; `rejected` required_if:type,0; `archive` nullable\|array; `archive.*` file\|max:10240 |
| `StoreSubjectRequest` | `title` required\|max:255; `sector_id` required\|exists; `email` required\|email; `responsible` required |
| `StoreSectorRequest` | `title` required\|min:2\|max:255 |
| `StoreOriginRequest` / `StoreResponseMethodRequest` / `StoreManifestationTypeRequest` / `StoreCannedResponseRequest` | `title` required\|min:5\|max:255 (+ `message` required no canned response) |
| `StoreContestationRequest` | `protocol` required; `contestation_token` required; `message` required\|max:5000 |

> **Remover** `OmbudsmanPortalRequest` (a classe atual tem `authorize() = false` e regras apontando para tabela inexistente).

