# Concursos e PSS — Rotas, Controllers e Validações

> Todas as rotas citadas existem **apenas na v5**. A v6 não declara nenhuma delas.

---

## 1. Rotas do CMS (`v5/routes/basepro.php`)

Todas dentro de `Route::group(['prefix' => 'cms', 'before' => 'csrf'], ...)` (linha 35)
e do sub-grupo `Route::middleware(['check.logged'])->group(...)` (linha 236).
Prefixo efetivo: **`/cms`**.

| # | Método | URI | Nome (`->name()`) | Controller@ação | Middleware |
|---|---|---|---|---|---|
| 1 | GET | `/cms/concursos-pss` | `concurso-pss.index` | `Prefecture\ConcursoPssController@index` | `check.logged` + `check.route.permission` |
| 2 | GET | `/cms/concursos-pss/cadastrar` | `concurso-pss.create` | `Prefecture\ConcursoPssController@create` | `check.logged` + `check.route.permission` |
| 3 | GET | `/cms/concursos-pss/editar/{id}` | `concurso-pss.edit` | `Prefecture\ConcursoPssController@edit` | `check.logged` + `check.route.permission` |
| 4 | PUT | `/cms/concursos-pss/atualizar/{id}` | `concurso-pss.update` | `Prefecture\ConcursoPssController@update` | `check.logged` + `check.route.permission` |
| 5 | POST | `/cms/concursos-pss` | `concurso-pss.store` | `Prefecture\ConcursoPssController@store` | `check.logged` + `check.route.permission` |
| 6 | DELETE | `/cms/concursos-pss/excluir/{id}` | `concurso-pss.destroy` | `Prefecture\ConcursoPssController@destroy` | `check.logged` + `check.route.permission` |
| 7 | DELETE | `/cms/concursos-pss/{id}/arquivo/{index}` | `concurso-pss.destroy-arquivo` | `Prefecture\ConcursoPssController@destroyArquivo` | `check.logged` + `check.route.permission` |
| 8 | GET | `/cms/datatable/concurso-pss` | `datatable.concurso-pss` | `Prefecture\DatatableController@get_concurso_pss` | `check.route.permission` |

> As rotas 4–7 **não** declaram `check.logged` individualmente — herdam o grupo
> iniciado na linha 236 (que fecha na linha 385). A rota 8 fica **fora** desse
> grupo: só tem `check.route.permission`, vindo do construtor de
> `v5/app/Http/Controllers/Prefecture/Controller.php:22`.

**Divergência de nomenclatura (importante):** a **URL** usa o plural
`concursos-pss`, enquanto o **nome da rota** e o **slug da resource** usam o
singular `concurso-pss`. O `CheckRoutePermission` compara as duas formas e aceita
qualquer uma (`hasAnyAccess`) — mas a resource cadastrada é apenas a singular.

## 2. Rotas do Portal (`v5/routes/web.php`)

Dentro de `Route::middleware(['check.site.manutention'])->group(...)` (linha 5),
prefixo: raiz do site.

| # | Método | URI | Nome | Controller@ação | Middleware |
|---|---|---|---|---|---|
| 9 | ANY | `/concursos-pss` | `concursos-pss.index` | `Portal\ConcursosPssController@index` | `check.site.manutention` |
| 10 | GET | `/concurso-pss/{slug}` | `concursos-pss.show` | `Portal\ConcursosPssController@show` | `check.site.manutention` |

- A rota 9 aceita **qualquer verbo** (`Route::any`), inclusive POST — foi assim que
  o filtro GET do formulário funciona e também é o canal das exportações
  (`?type=xls|pdf|csv|txt`).
- A rota 10 usa o **singular** no path: `/concurso-pss/{slug}`.
- Não há rota de API (`v5/routes/api.php`) para este módulo.

### 2.1 Breadcrumbs (`v5/routes/breadcrumbs.php:190-198`)

```php
Breadcrumbs::register('concursos-pss', function ($breadcrumbs) {
    $breadcrumbs->parent('home');
    $breadcrumbs->push('Concursos e PSS', route('concursos-pss.index'));
});

Breadcrumbs::register('concursos-pss.show', function ($breadcrumbs, $concurso) {
    $breadcrumbs->parent('concursos-pss');
    $breadcrumbs->push($concurso->tipo . ' nº ' . $concurso->numero . '/' . $concurso->ano);
});
```

## 3. Controller do CMS — `ConcursoPssController`

Caminho: `v5/app/Http/Controllers/Prefecture/ConcursoPssController.php`
(classe `App\Http\Controllers\Prefecture\ConcursoPssController`, estende
`Prefecture\Controller`, usa `App\Http\Traits\FileTrait`).

| Método | Linha | Retorno | Comportamento |
|---|---|---|---|
| `index()` | 15 | View | Renderiza `prefecture.concursos-pss.index`. Sem consulta ao banco. |
| `create()` | 20 | View | Renderiza `prefecture.concursos-pss.create`. |
| `store(ConcursoPssRequest)` | 25 | Redirect | Valida, salva anexos, `ConcursoPss::create()`, `flash(...)->success()`, redireciona para `concurso-pss.index`. |
| `edit($id)` | 52 | View | `ConcursoPss::find($id)`; se não achar, `flash('Registro não encontrado!')->error()` e redireciona ao índice. |
| `update(ConcursoPssRequest, $id)` | 64 | Redirect | `findOrFail`, mescla arquivos existentes + novos, `$concurso->update($data)`. |
| `destroy($id)` | 99 | JSON | `findOrFail` + `delete()` (soft). Responde `{"success": ...}` 200 ou `{"erro": ...}` 500. |
| `prepareData($request)` | 111 | array | Whitelist de 7 campos + conversão de `validade`. |
| `destroyArquivo($id, $index)` | 122 | JSON | Remove o arquivo na **posição** `$index` do array JSON; 404 se a posição não existir. |

### 3.1 `prepareData()` — conversão de `validade`

```php
private function prepareData($request): array
{
    $data = $request->only(['tipo', 'numero', 'ano', 'descricao', 'situacao', 'vagas_ofertadas', 'vagas_preenchidas']);

    if (!empty($request->validade)) {
        $data['validade'] = date('Y-m-d', strtotime(str_replace('/', '-', $request->validade)));
    }

    return $data;
}
```

`arquivo` **não** entra no whitelist: é tratado à parte, só quando
`$request->hasFile('arquivo')` é verdadeiro. Sem novo upload, o `arquivo` anterior
é preservado (o campo simplesmente não vai no `update()`).

### 3.2 `store()` — upload dos anexos

```php
if ($request->hasFile('arquivo')) {
    $items = [];
    $descs = $request->input('arquivo_desc', []);
    foreach ($request->file('arquivo') as $i => $file) {
        $path = $this->saveUpload($file, 'docs', null, null, false);
        $items[] = ['path' => "docs/{$path}", 'description' => $descs[$i] ?? ''];
    }
    $data['arquivo'] = json_encode($items);
}
```

- `saveUpload($file, 'docs', null, null, false)` → `v5/app/Http/Traits/FileTrait.php:50`;
  o 5º argumento `false` **desliga a compressão** (o método é pensado para imagens).
- O nome do arquivo é gerado por `getFileName()`: `uniqid() . str_random(5) . '.' . extensão`,
  em minúsculas (`v5/app/Http/Traits/FileTrait.php:78-82`).
- O disco é o **default** (`FILESYSTEM_DRIVER`, default `public` —
  `v5/config/filesystems.php:16`); o caminho relativo `docs/` é prefixado no controller.
- O download no portal usa `url('storage/' . $item['path'])`, o que exige o symlink
  `public/storage` → `storage/app/public` criado por `php artisan storage:link`
  (**não versionado** no repositório).

### 3.3 `update()` — mesclagem de anexos

```php
$existing = $concurso->arquivo
    ? (json_decode($concurso->arquivo, true) ?: [$concurso->arquivo])
    : [];

$existing = array_map(function ($item) {
    return is_array($item) ? $item : ['path' => $item, 'description' => ''];
}, $existing);
```

Os arquivos já enviados são preservados e os novos são **anexados ao final**. Não há
remoção de arquivo pelo formulário (só pelo botão de lixeira, via AJAX).

### 3.4 `destroyArquivo()` — remoção por índice

```php
$arquivos = json_decode($concurso->arquivo, true) ?: [$concurso->arquivo];
if (!isset($arquivos[$index])) {
    return response()->json(['erro' => 'Arquivo não encontrado.'], 404);
}
array_splice($arquivos, $index, 1);
$concurso->arquivo = !empty($arquivos) ? json_encode(array_values($arquivos)) : null;
```

O índice é **posicional**: remover o primeiro arquivo renumera os demais, e a view
recarrega a lista a partir do `array_values` re-serializado. O arquivo físico
continua no disco — `deleteFile()` **não** é chamado.

## 4. Controller do Portal — `ConcursosPssController`

Caminho: `v5/app/Http/Controllers/Portal/ConcursosPssController.php`
(estende `App\Http\Controllers\Admin\Controller` — **classe do namespace Admin**, que
fornece `getTemplateActived()`).

### 4.1 `index()` — filtros e ordenação

```php
$query = ConcursoPss::query()->orderBy('ano', 'desc')->orderBy('created_at', 'desc');

$query->when($request->ano, function ($q) use ($request) {
    $q->where('ano', $request->ano);
});

$query->when($request->tipo, function ($q) use ($request) {
    $q->where('tipo', $request->tipo);
});

$query->when($request->busca, function ($q) use ($request) {
    $busca = $request->busca;
    $q->whereRaw(
        "translate(lower(descricao), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike ?",
        ['%' . clean_slug($busca) . '%']
    );
});
```

| Filtro | Parâmetro | Semântica | SQL |
|---|---|---|---|
| Ano | `ano` | igualdade exata | `where ano = ?` |
| Tipo | `tipo` | igualdade exata | `where tipo = ?` |
| Texto | `busca` | "contém", **só em `descricao`**, sem acento | `whereRaw` com `translate(lower(descricao)) ilike '%termo%'` |

- `clean_slug()` (`v5/app/Helpers/functions.php:780`) remove acentos e caracteres não
  alfanuméricos do **termo buscado**; o lado esquerdo (`descricao`) é normalizado pelo
  `translate()` do PostgreSQL. As duas tabelas de mapeamento têm 26 entradas cada,
  incluindo espaço e apóstrofo → `-`.
- **Não** há filtro por `situacao` nem por `numero` (edital) — apesar de serem
  campos indexáveis.
- O `when()` do Laravel ignora valores falsy: `ano=0` seria ignorado, `ano=''` também.

### 4.2 Demais métodos

| Método | Linha | Comportamento |
|---|---|---|
| `show($slug)` | 63 | `ConcursoPss::where('slug', $slug)->firstOrFail()` → 404 se não existir. |
| `export($result, $type)` | 73 | `xls` → `Excel::download`; `pdf` → `PDF::loadView` + `download`; `csv` → `Excel::download`; qualquer outro → TXT. |
| `gerarTxt($models)` | 89 | Monta texto separado por `\|` com cabeçalho fixo. |
| `exportTxt($models, $filename)` | 102 | `Response::make` com `Content-Type: plain/txt`. |
| `paginate($items, $perPage, $page)` | 114 | `LengthAwarePaginator` manual sobre `collect($items)`. |

### 4.3 Exportação

| `?type=` | Formato | Motor | Nome do arquivo |
|---|---|---|---|
| `xls` | XLSX | `maatwebsite/excel` + `ConcursosPssExport` (`FromView`) | `concursos-pss.xlsx` |
| `pdf` | PDF | `barryvdh/laravel-dompdf`, view `exports.concursos-pss` | `concursos-pss.pdf` |
| `csv` | CSV | `maatwebsite/excel` + `ConcursosPssExport` | `concursos-pss.csv` |
| `txt` (ou ausente) | TXT texto | `Response::make` | `concursos-pss.txt` |

A exportação usa **o mesmo conjunto filtrado** da listagem — o `?type=` é avaliado
**antes** da paginação (`v5/app/Http/Controllers/Portal/ConcursosPssController.php:47-49`),
portanto exporta **todos** os registros filtrados, não só a página 1.

## 5. Form Request — `ConcursoPssRequest`

Caminho: `v5/app/Http/Requests/ConcursoPssRequest.php`.

| Campo | Regra | Efeito observável |
|---|---|---|
| `tipo` | `required|string|max:60` | obrigatório, até 60 chars |
| `numero` | `required|string|max:20` | obrigatório, até 20 chars |
| `ano` | `required|integer|min:1900|max:2100` | obrigatório, faixa 1900–2100 |
| `descricao` | `required|string` | obrigatório, sem limite de tamanho |
| `situacao` | `required|string|max:60` | obrigatório, até 60 chars |
| `vagas_ofertadas` | `nullable|integer|min:0` | opcional, não negativo |
| `vagas_preenchidas` | `nullable|integer|min:0` | opcional, não negativo |
| `validade` | `nullable|date_format:d/m/Y` | opcional, **exatamente** `dd/mm/aaaa` |
| `arquivo` | `nullable|array` | o input é `arquivo[]` |
| `arquivo.*` | `file|mimes:pdf|max:10240` | cada item precisa ser PDF, até 10 MB |
| `arquivo_desc` | `nullable|array` | input `arquivo_desc[]` |
| `arquivo_desc.*` | `nullable|string|max:255` | descrição por arquivo |

Mensagens customizadas em `messages()` (linha 34) para `tipo.required`,
`numero.required`, `ano.required`, `ano.integer`, `descricao.required`,
`situacao.required`, `arquivo.array`, `arquivo.*.mimes`, `arquivo.*.max`.

Observações:
- `authorize()` retorna `true` **sempre** — a autorização real vem do middleware
  `check.route.permission`, não do Request.
- A variável `$isUpdate = $this->isMethod('put');` (linha 16) é **calculada e nunca
  usada** — regras idênticas para create e update.
- Não existe validação de que `vagas_preenchidas <= vagas_ofertadas`.
- `arquivo_desc.*` com `nullable` **string**: um array vazio passa.

## 6. Permissões exigidas

O par `resources` (id 16, `slug = 'concurso-pss'`) + `roles.permissions` (JSON) do
Sentinel define o acesso. Formato do JSON: `{"<resource-slug>.<ação>": true}`.

| Ação | Permissão esperada | Verificação |
|---|---|---|
| Ver a listagem | `concurso-pss.index` | `CheckRoutePermission` (via nome da rota) |
| Abrir o formulário de cadastro | `concurso-pss.create` | `has_access(['concurso-pss.create'])` em `index.blade.php` |
| Salvar | `concurso-pss.create` | `CheckRoutePermission` (rota `concurso-pss.store`) |
| Editar | `concurso-pss.edit` | `CheckRoutePermission` + `data.edit` do DataTable |
| Excluir | `concurso-pss.delete` | `data.delete` do DataTable (`struct_views`) |
| Remover anexo | `concurso-pss.destroy-arquivo` | `CheckRoutePermission` (nome da rota) |

- `super-admin` passa por tudo (`has_access()` retorna `true` e o middleware
  também o isenta — `v5/app/Http/Middleware/CheckRoutePermission.php:23-30`).
- **Estado real:** nenhum papel na base v5 possui a chave `concurso-pss.*` em
  `roles.permissions` (verificado com `LIKE '%concurso-pss%'` em `roles`). Só
  `super-admin` acessa o módulo até que um administrador marque as permissões na
  tela de Perfis.
- `struct_views()` (linha 508) injeta `edit`, `delete`, `active` e `inactivate`
  em cada linha do JSON do DataTable; `delete` só é liberado para
  `super-admin`/`administrador` no caso especial de `diario-oficial` — para
  `concurso-pss` vale a regra geral (`has_access('concurso-pss.delete')`).
- A tela de cadastro e a de edição **não** verificam permissão no Blade; elas
  confiam no middleware.

## 7. Saídas de API / Resources

Nenhuma. O módulo não expõe JSON próprio:
- o CMS usa o JSON do Yajra (`DataTables::of(...)->make(true)`), que inclui as
  colunas da view mais `edit`/`delete`/`active`/`inactivate`;
- o portal responde HTML.

## 8. Ausência de API REST

Não há `v5/routes/api.php` para concursos, nem `Resources/`, `Presenters/` ou
`Transformers/` do módulo. A futura API precisa ser criada do zero (ver
`06-MIGRACAO-MODERNA.md`).
