# 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).

O módulo é o **catálogo**, não o motor (ADR-049): a serialização é do service
`Services\OpenData\Contracts\DatasetExporter`. Um dataset é **curado** (matriz `columns`/`rows` no
banco) ou **gerado** (`source` aponta para uma `DatasetSource` registrada; nenhuma linha copiada).

- **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}
│   ├── Actions/DadosAbertosExport.php       # adaptador fino: curado → of(); gerado → byId()
│   ├── DTOs/DadosAbertosPresentation.php    # visão pública (metadados resolvidos)
│   └── Support/{DadosAbertosPresent,DadosAbertosWidgetFeed}.php
├── 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/                    # contrato público, CMS, widget, paridade de bytes e dataset gerado
```

## 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:** `Services\OpenData\Contracts\DatasetExporter` (contrato; a implementação é resolvida
  pelo container) e as exceções de domínio do gate.
- **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**). O export também emite o span/log do motor
  (`open-data.export`).
- **Auditoria:** as mutações entram na trilha pelo `GlobalAuditSubscriber` (ADR-035).
- **Sitemap:** contribui catálogo e datasets publicados (`SitemapRegistry`, ADR-027 §5).

## Dataset gerado por fonte (ADR-049, P5.5.S3)

- **Banco:** `dados_abertos.datasets.source` (texto, nullable, indexado) + `source_options` (jsonb,
  default `{}`). Migração aditiva e idempotente; `down()` é no-op documentado (ADR-017: não dropa
  dado). Dataset gerado mantém `columns`/`rows` **vazios** — a fonte é a única verdade.
- **Model/DTO:** `DadosAbertosDataset::isGenerated()`; `DadosAbertosData::$source/$sourceOptions`;
  `DadosAbertosSave` zera `columns`/`rows` ao gerar e limpa `source`/`source_options` ao voltar para
  curado.
- **Export:** `DadosAbertosExport` é adaptador — curado → `DatasetExporter::of(ArrayDatasetSource)`,
  gerado → `DatasetExporter::byId($source)`. O curado mantém **os mesmos bytes/`ETag`**
  (`Test/DadosAbertosExportParityTest.php` pina os três formatos).
- **Superfícies públicas:** catálogo, `show`, API/DCAT, `schema.org` e sitemap usam a
  `DadosAbertosPresentation` (metadados e `updated_at` da fonte quando gerado). Fonte
  inexistente/inativa/PII sem base legal ou sem allowlist → **404** (e o registro some do
  catálogo/API/sitemap na mesma leitura).
- **CMS:** select "Fonte" só com `DatasetExporter::sources()` (módulos ativos e publicáveis);
  dataset gerado não edita colunas/linhas nem oferece upload de CSV, e o formulário avisa quando a
  fonte atual deixou de ser publicável. Catálogo vazio (nenhum módulo publica dado) é **explicado**
  no próprio campo e o dataset curado continua disponível. Fonte inválida na gravação = erro de
  validação com mensagem no campo (`aria-invalid`/`aria-describedby`), resumo `role="alert"`
  (`x-cms::form-errors`) e foco no primeiro campo inválido (história E2E `33`).

### Receita do módulo que publica dado

```php
// Módulo dono (ex.: Noticias), em Logic/Datasets/NoticiasDatasetSource.php
// implements Core\Contracts\Ports\DatasetSource: id() = 'noticias.publicadas', metadata(),
// columns() e rows() lazy. O catálogo só referencia o id — nada de copiar linhas.

// ModuleProvider::register()
app(Core\Contracts\DatasetRegistry::class)->register(NoticiasDatasetSource::class);
```

## 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 exec cmd='vendor/bin/phpunit src/modules/Portal/DadosAbertos/Test'  # catálogo + gerado
make check            # cadeia completa (lint, PHPStan max, gates, testes, cobertura)
make check E2E=1      # + e2e/axe/sniff/perf quando a UI muda
```
