# Módulo Dados Abertos (`Portal/DadosAbertos`)

> Contrato de leitura do módulo (ADR-004/ADR-014). Mantenha sincronizado com o código.

## Propósito

Publicar **dados abertos reutilizáveis** (ADR-018): catálogo público, detalhe com metadados
DCAT/INDA + `schema.org/Dataset`, download em **CSV/JSON/XML** com URL estável/`ETag`/checksum e
**API pública** somente leitura. Gestão (CRUD) no CMS. Fora do escopo deste item: DSAR/retém/LGPD
(F5.I2.T2) e o componente CMS de configuração do service (F5.I2.T3).

- **Grupo:** `Portal` (organizacional — **não** entra no token, ADR-014).
- **Áreas:** portal · CMS · API.
- **Ativação:** `php artisan core:module:activate DadosAbertos [--disable]` (ADR-034).

## Estrutura

```
src/modules/Portal/DadosAbertos/
├── ModuleProvider.php        # ÚNICO arquivo de declaração (register + config) — ADR-014
├── Database/{Migrations,Models}   # demo em src/demonstration/DadosAbertos/ (ADR-041)
├── Logic/{Actions,Controllers,DTOs,Requests}
│   └── Support/DadosAbertosWidgetFeed.php   # feed do widget (1 query, ordem determinística)
├── Widgets/DadosAbertosUltimosWidget.php    # fonte `dadosabertos.ultimos` (P5.S5.1)
├── Routes/{portal,cms,api}.php
├── Seo/                      # DadosAbertosSchema (JSON-LD/DCAT), DadosAbertosSitemapSource
├── Layout/Views/{index,show,cms/{index,form}}.blade.php
│   └── widgets/dadosabertos-ultimos.blade.php
├── Lang/{pt_BR,en,es,fr}/{menu,index,show,cms,widget}.php
└── Test/{DadosAbertosTest,DadosAbertosWidgetTest}.php
```

## Token canônico (ADR-004)

**`dados_abertos`** — classes (`DadosAbertos*`), rotas (`dados-abertos.*`), tabela
(`dados_abertos.datasets`), logs `dados-abertos.*`. Verificação: `tools/name-check.sh --grep dados_abertos`.

## Dependências

- **Services:** nenhum import de implementação.
- **Core:** `Core\Contracts\{Clock,Telemetry}` (injetados), `Core\Contracts\SitemapRegistry`,
  `Core\Contracts\WidgetRegistry` (fonte de widget, P5.S5.1), `Core\Access\Permission`.
- **Outros módulos:** nenhum import.

## Rotas

| Nome | Método | URI | Acesso |
|---|---|---|---|
| `dados-abertos.index` | GET | `/dados-abertos` | pública |
| `dados-abertos.show` | GET | `/dados-abertos/{slug}` | pública |
| `dados-abertos.export` | GET | `/dados-abertos/{slug}/{format}` | pública (`format` ∈ csv/json/xml) |
| `dados-abertos.api.index` | GET | ``v1/datasets` (host `api.*`)` | pública (`throttle:open-data`) |
| `dados-abertos.cms.*` | GET/POST/PUT/DELETE | `/cms/dados-abertos...` | `dados_abertos.{ver|editar|aprovar|arquivar}` |

## Permissões (ADR-033)

Declaradas em `ModuleProvider::config()` (`'permissions' => Permission::ACTIONS`) e aplicadas com
`Permission::authorize()`: `dados_abertos.{ver|editar|aprovar|arquivar}`.

## Eventos e observabilidade (ADR-024/ADR-035)

- **Emite:** `dados-abertos.save`, `dados-abertos.status`, `dados-abertos.export` — span + `log_event`
  com `{module, action, id}` (**sem PII**).
- **Auditoria:** as mutações entram na trilha pelo `GlobalAuditSubscriber` (ADR-035).
- **Sitemap:** contribui catálogo e datasets publicados (`SitemapRegistry`, ADR-027 §5).

## Widget no layout (P5.S5.1)

`ModuleProvider::register()` registra a fonte **`dadosabertos.ultimos`** no `WidgetRegistry` do
Core (embed permitido, import proibido — ADR-013).

- `title` = `dados_abertos::widget.title` (4 locales), ícone `lucide-database`, `embeddable = true`,
  slots `main.left`, `main.right`, `main.after` (regiões de conteúdo do shell).
- **Leitura:** `DadosAbertosWidgetFeed::latest()` — **uma** query pelo escopo `publicados()`
  (status `publicado`, `published_at` já alcançado, sem soft-delete), ordem
  `published_at DESC, id DESC`, recorte de `config('dados_abertos.widget_limit')` (default 5, teto
  12; valor inválido cai no default). Datasets não têm mídia de catálogo — zero consulta adicional.
- **HTML:** lista semântica com `<h3>` por item, data por `LocalizedDate`, destino por `route()`
  (locale correto — ADR-012); sem `<script>`, `style=` ou `id` fixo no fragmento.
- **Ativação (resolvida na S5.2):** o id continua `dadosabertos.ultimos` (contrato da fase) e o
  `InMemoryWidgetRegistry` resolve o dono **pelo manifesto** — o prefixo `dadosabertos` casa com o
  módulo `DadosAbertos` (comparação sem distinção de caixa), não com o `Str::studly` ingênuo que
  produzia `Dadosabertos`. Desligar `DadosAbertos` remove a fonte de `get()`/`all()`/`forSlot()`
  (slot vazio, sem 500) e deixa um `widget.module_inactive` no log estruturado. A resolução é
  memorizada por módulo na request (sem N+1) e o dono aparece no catálogo do `/cms/layout`.

## Convenções (checklist)

- [x] `declare(strict_types=1)`; DI (sem `new`/`resolve` no corpo); sem traits/abstract/generics.
- [x] Dados em `jsonb` (`columns`/`rows`/`tags`/`metadata`), sem HTML/CSS/UI no banco (ADR-017).
- [x] Export **determinístico** com checksum SHA-256 (`ETag`/304) e neutralização de CSV injection.
- [x] 4 locales no `Lang/` com paridade de chaves (`ModuleI18nTest`).
- [x] A11y eMAG/WCAG 2.2 AA; alvos ≥ 2.75rem no mobile (ADR-025).

## Verificação

```bash
make exec cmd='bash tools/name-check.sh --grep dados_abertos'
make exec cmd='php tools/arch-check' && make exec cmd='php tools/migration-check'
make exec cmd='vendor/bin/phpunit --filter=DadosAbertosWidgetTest'   # widget (P5.S5.1)
make check            # cadeia completa (lint, PHPStan max, gates, testes, cobertura)
make check E2E=1      # + e2e/axe/sniff/perf quando a UI muda
```
