---
id: SVC-DASHBOARD
tipo: servico
onda: 2
status: concluido
unidades: [SVC-DASHBOARD]
fontes:
  - V2/Services/Dashboard/
depende_de: [CORE-AUTH, CORE-DATA, SVC-SEARCH, SVC-SEO, SVC-ACTIVITYLOGGER]
consumidores: [CORE-CMS]
criticidade: media
dono: SvcSearchDash
---

# Serviço Dashboard — estatísticas do CMS

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Em uma única requisição `GET /cms`, executa ~20 consultas e devolve 5 datasets (cards de conteúdo, atalhos, boas-vindas, ranking de páginas e treemap de acessos) para a view `v2-cms::dashboard`. |
| Entrada | `DashboardCmsController@index` (rota `v2.cms.dashboard`, middleware `web` + `CmsAuthenticate`), sessão Sentinel do usuário, flags em `v2_module_flags`. |
| Saída | Arrays para Blade: `contentStats` (`icon,count,label,color,sublabel`), `quickActions` (`icon,label,url,color`), `userWelcome` (`name,email,lastLogin,roles,avatar`), `seoRanking` (coleção de `url_path,total_visits,bot_visits[,module,page_title]`), `treemapData` (`name,children[{name,value,path,fullLabel,pct}],pct`). |
| Efeitos colaterais | Somente leitura no banco; `Schema::hasTable('v2_public_search_index')`; nenhum log, auditoria, cache ou escrita. |
| Particularidade central | É um **agregador sem cache**: cada card é um `count()` próprio, cada módulo é checado por `class_exists` + feature flag, e dois dos cinco métodos públicos (`getRecentActivity`, `getSystemHealth`) **não têm consumidor** — o controller nunca os chama. |
| Como se integra | `app(DashboardStatsService::class)` (autowiring) no controller; nenhuma rota, view ou migration própria; depende de models de 7 módulos. |
| Se removido, quebra | `GET /cms` (500 — `DashboardCmsController` exige o serviço no construtor). Nada mais o consome. |
| Superfície pública | `getContentStats()`, `getRecentActivity($limit = 10)`, `getQuickActions()`, `getSystemHealth()`, `getUserWelcome()`, `getSeoRanking($limit = 10)`, `getTreemapData($limit = 50)`; construtor `(ModuleFlagService, PermissionService)`. |

## 1. Efeito

### 1.1 Transformação que o serviço realiza

Entrada: requisição autenticada ao CMS. Saída: cinco arrays PHP prontos para render, montados por consultas independentes, sem transação e sem cache. O dashboard **não** cria, altera ou remove nada; o único efeito fora da resposta HTTP é a carga de leitura no PostgreSQL (e a resolução do usuário Sentinel na sessão).

### 1.2 Métricas exibidas (cada uma, sua consulta e seu custo)

| # | Card (`label`) | `sublabel` | Consulta | Custo | Dependências | Fonte |
|---|---|---|---|---|---|---|
| 1 | `Notícias Publicadas` (ícone `bi-newspaper`, cor `primary`) | `{N} rascunho(s)` | `News::published()->count()` + `News::draft()->count()` | 2 `COUNT` | model `News`, flag `Noticias` | `V2/Services/Dashboard/DashboardStatsService.php:56-73` |
| 2 | `Eventos Futuros` (`bi-calendar-event`, `success`) | `agendados` | `Evento::published()->upcoming()->count()` → `published_at <= now()` **e** `event_date_start >= today` | 1 `COUNT` | model `Evento`, flag `Agenda` | `:75-93`; `Evento.php:260-273` |
| 3 | `Mensagens Não Lidas` (`bi-envelope`, `warning`) | `{N} na caixa de entrada` | `MensagemContato::inbox()->unread()->count()` + `inbox()->count()` → `is_read=false AND is_archived=false` + `is_archived=false` | 2 `COUNT` | model `MensagemContato`, flag `FaleConosco` | `:95-113`; `MensagemContato.php:87-99` |
| 4 | `Vagas Abertas` (`bi-briefcase`, `info`) | `oportunidades ativas` | `JobOpening::open()->count()` → `is_active=true AND (closes_at IS NULL OR closes_at >= now())` | 1 `COUNT` | model `JobOpening`, flag `TrabalheConosco` | `:114-130`; `JobOpening.php:232-238` |
| 5 | `Candidaturas Pendentes` (`bi-people`, `secondary`) | `aguardando análise` | `JobApplication::pending()->count()` → `status='pending'` | 1 `COUNT` | model `JobApplication`, flag `TrabalheConosco` | `:132-148`; `JobApplication.php:126-129` |
| 6 | `Leads de Associação` (`bi-person-badge`, `primary`) | `pendentes` | `MembershipLead::pending()->count()` | 1 `COUNT` | model `MembershipLead`, flag `AssocieSe` | `:150-166`; `MembershipLead.php:105-108` |
| 7 | `Parceiros Institucionais Ativos` (`bi-building`, `primary`) | `cadastrados` | `Parceiro::active()->count()` | 1 `COUNT` | model `Parceiro`, flag `ParceirosInstitucionais` | `:168-183` |
| 8 | `Páginas Indexadas` (`bi-search-heart`, `success`) | `no buscador do portal` | `Schema::hasTable('v2_public_search_index')` + `DB::table('v2_public_search_index')->count()` | 1 `information_schema` + 1 `COUNT` | **SVC-SEARCH** (sem flag de módulo, sem permissão) | `:184-198` |

Total dos cards: **10 `COUNT`** + 1 `hasTable` + **1 `SELECT`** de `v2_module_flags` (cacheada estaticamente em `ModuleFlagService::$flags`, `V2/Core/Services/ModuleFlagService.php:20,42-65`).

| Dataset | Conteúdo | Consulta | Custo |
|---|---|---|---|
| `quickActions` | até 6 botões: Nova Notícia, Novo Evento, Mensagens Recebidas, Nova Página, Gerenciar Permissões, Auditoria de E-mail | 6 × `PermissionService::hasPermission` → 6 × `V2Role::find($user->v2_role_id)` (sem cache) | 6 `SELECT` em `v2_roles` + resolução de `inRole('super-admin')` (`:260-332`; `PermissionService.php:40-57,71-105`) |
| `userWelcome` | nome, e-mail, último acesso, papéis | `Sentinel::getUser()` + lazy load `$user->roles` + leitura de `last_login` | 1 `SELECT` de papéis; zero se não houver sessão (`:453-499`) |
| `seoRanking` | 10 URLs mais visitadas com visitas e bots | `SEOService::getPageRanking(10)`: `count(*)` / `sum(is_bot)` sobre `v2_seo_page_visits`, com **17 prefixos** (cada um gera `not like '<p>/%'` **e** `!= '<p>'`, portanto 34 condições) **e 35 extensões** (`not like '%.<ext>'`), totalizando **69 predicados**; depois `group by url_path` e `order by total_visits desc limit 10`, seguidos de `enrichRankingWithSearchIndex` (`whereIn slug`) sobre `v2_public_search_index`. A lista literal dos 17 prefixos e 35 extensões é mantida por `03_servicos-ui/seo/spec.md` (unidade SVC-SEO) e não é duplicada aqui | 1 agregação potencialmente full-scan + 1 `SELECT IN` (`:502-510`; `SEOService.php:203-227`) |
| `treemapData` | hierarquia `módulo → páginas`, com `pct` | **repete** `getSeoRanking(15)` — a mesma agregação pesada roda duas vezes por request | +1 agregação + 1 `SELECT IN` (`:530-540`) |

Custo total observado por `GET /cms`: ~10 `COUNT` + 6 `SELECT` de papel + 2 agregações de visitas + 2 `SELECT IN` + 1 de flags + 1 de papéis + 1 `hasTable` ≈ **23 consultas**, todas sequenciais, sem cache de página (o `PageCacheMiddleware` exclui `cms*`, `V2/Core/Middleware/PageCacheMiddleware.php:16-23`).

### 1.3 Contrato observável

| Chamada | Resultado |
|---|---|
| `getContentStats()` | `array` de até 8 cards; módulo desativado/ausente → card omitido; falha de query → card omitido silenciosamente |
| `getContentStats()` com todas as flags desligadas | `[]`; a view esconde a grade (`dashboard.blade.php:47-66`) |
| `getRecentActivity(10)` | `array` de até 10 itens `{icon, causer, action, subject, timeAgo, createdAt}`; exceção → `[]` |
| `getQuickActions()` | `[]` sem usuário; senão até 6 itens `{icon, label, url, color}` |
| `getQuickActions()` com rota de módulo inexistente | `InvalidArgumentException` **não capturada** → 500 no dashboard (`vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:299-306`) |
| `getSystemHealth()` | `array` de até 7 indicadores `{icon,label,value,status}`; falha de Redis/BD vira `status=danger`, não exceção |
| `getUserWelcome()` | `{name,email,lastLogin,roles,avatar}`; sem sessão → `name='Visitante'`, `roles='—'` |
| `getSeoRanking(10)` | `Collection`; qualquer exceção → `collect()` vazio |
| `getTreemapData(15)` | `array` com `name`+`children`; ranking vazio ou soma 0 → `[]` |
| `GET /cms` | `200` HTML `v2-cms::dashboard` |

### 1.4 O que o serviço deliberadamente NÃO faz

- **Não escreve** nada, **não audita** e **não loga** (as falhas são comentadas como “Silencia”, `:70-72` e equivalentes).
- **Não cacheia**: não há `Cache::remember` em nenhum caminho vivo.
- **Não filtra por permissão do usuário nos cards** — o RBAC só decide os atalhos. Um usuário sem acesso a Notícias vê a contagem de notícias.
- **Não respeita escopo regional**: `RegionalScopeService` não é injetado; operador regional vê números globais.
- **Não mostra saúde do sistema nem atividade recente na tela** (métodos existem, controller não os chama).
- **Não atualiza em tempo real**: é renderização server-side por request, sem polling nem JS de refresh.

## 2. Abordagem

### 2.1 Estratégia e por quê

Agregação **pull-based no controller**: o serviço não mantém estado, não tem tabela de métricas e não agenda job. Cada número é calculado sob demanda, com dois mecanismos de tolerância a falhas:

1. `class_exists(Model::class) && ModuleFlagService::isActive('<Nome>')` antes de cada card — permite instalar o CMS com módulos ausentes ou desativados sem erro (`:57-59,76-78,94-96,...`).
2. `try/catch` individual por card — qualquer erro de SQL esconde o card em vez de derrubar o dashboard (`:60-72`).

A motivação `[I]` é robustez de instalação modular: o dashboard é a primeira tela após o login e não pode quebrar por causa de um módulo. O preço é que erros reais (tabela renomeada, coluna removida) ficam invisíveis.

```mermaid
flowchart TD
  A[GET /cms] --> B[DashboardCmsController@index]
  B --> C[getSeoRanking 10]
  B --> D[getContentStats]
  B --> E[getQuickActions]
  B --> F[getUserWelcome]
  B --> G[getTreemapData 15]
  D --> D1{para cada módulo:<br/>class_exists + isActive}
  D1 -->|ativo| D2[count no model]
  D1 -->|inativo/ausente| D3[omite card]
  D2 -->|exceção| D3
  C --> H[SEOService::getPageRanking]
  G --> H
  H --> I[v2_seo_page_visits]
  H --> J[v2_public_search_index<br/>enriquecimento por slug]
  G --> K[treemap: agrupa por module]
  F --> L[v2_roles / Sentinel]
  E --> M[PermissionService por módulo]
```

### 2.2 Mecânica passo a passo

1. `DashboardCmsController::index()` monta o array de cinco chaves e chama `view('v2-cms::dashboard', $data)` (`V2/Core/Controllers/DashboardCmsController.php:34-45`).
2. `getSeoRanking(10)` delega a `SEOService::getPageRanking(10)`; o serviço monta a consulta com exclusões de prefixo e extensão, agrega por `url_path`, e enriquece cada linha com `module` e `title` do índice público (`SEOService.php:200-276`).
3. `getContentStats()` percorre uma sequência fixa de módulos; para cada um: checa classe, checa flag, executa 1–2 `COUNT`, empacota `icon/count/label/color/sublabel` e captura exceções (`:52-201`).
4. `getQuickActions()` resolve o usuário Sentinel e testa 6 permissões; cada teste bem-sucedido gera um link com `route(..., [], false)` (URL relativa) (`:260-332`).
5. `getUserWelcome()` formata nome (ou e-mail), papéis e `last_login` (`:453-499`).
6. `getTreemapData(15)` **chama de novo** `getSeoRanking(15)`, soma visitas, agrupa por `module` (ou primeiro segmento da URL), calcula `pct` por página e por módulo (`:530-583`).
7. A view renderiza cards, treemap ECharts (`assets/vendor/echarts/echarts.min.js`, `dashboard.blade.php:99,113`) e a tabela de ranking; seções vazias são ocultadas por `@if(!empty(...))`.

### 2.3 Trechos de código pivô

```php
// V2/Services/Dashboard/DashboardStatsService.php:52-73 — o padrão de todo card:
// classe existe? flag ativa? então dois counts; erro desaparece.
public function getContentStats()
{
    $stats = [];

    // Notícias
    if (class_exists(\V2\Modules\Publicacoes\Noticias\Models\News::class)
        && $this->moduleFlag->isActive('Noticias')
    ) {
        try {
            $published = \V2\Modules\Publicacoes\Noticias\Models\News::published()->count();
            $drafts = \V2\Modules\Publicacoes\Noticias\Models\News::draft()->count();
            $stats[] = [
                'icon' => 'bi-newspaper', 'count' => $published,
                'label' => 'Notícias Publicadas', 'color' => 'primary',
                'sublabel' => $drafts . ' rascunho(s)',
            ];
        } catch (\Exception $e) {
            // Silencia
        }
    }
```
`$stats[]` só entra quando o `try` termina: a ordem dos cards segue a ordem do código, não a flag nem a permissão.

```php
// V2/Services/Dashboard/DashboardStatsService.php:184-198 — o único card que não é módulo:
// conta direto a tabela do índice público, sem flag e sem permissão.
if (\Illuminate\Support\Facades\Schema::hasTable('v2_public_search_index')) {
    try {
        $indexed = DB::table('v2_public_search_index')->count();
        $stats[] = [
            'icon' => 'bi-search-heart',
            'count' => $indexed,
            'label' => 'Páginas Indexadas',
            'color' => 'success',
            'sublabel' => 'no buscador do portal',
        ];
    } catch (\Exception $e) {
        // Silencia
    }
}
```
O número inclui Eventos, Parceiros, Vagas e Diretoria, além de eventuais órfãos do índice (ver `indexacao.md`), embora o rótulo diga “Páginas”.

```php
// V2/Services/Dashboard/DashboardStatsService.php:502-510 — o ranking é terceirizado ao SEO,
// que por sua vez consulta o índice público para enriquecer.
public function getSeoRanking($limit = 10)
{
    try {
        $seo = app(\V2\Services\SEO\Services\SEOService::class);
        return $seo->getPageRanking($limit);
    } catch (\Exception $e) {
        return collect();
    }
}
```
Falha de SEO esvazia ranking **e** treemap (que depende dele), mas o dashboard continua `200`.

```php
// V2/Services/Dashboard/DashboardStatsService.php:530-540 — o treemap refaz a consulta do ranking.
public function getTreemapData($limit = 50)
{
    $ranking = $this->getSeoRanking($limit);
    if ($ranking->isEmpty()) {
        return [];
    }

    $totalVisits = $ranking->sum('total_visits');
    if ($totalVisits == 0) {
        return [];
    }
    // ... agrupa por resolveModuleName($row) e calcula pct
}
```
`getSeoRanking(15)` aqui é uma **segunda** execução da agregação já feita com `10` no controller; não há memoização.

```php
// V2/Services/Dashboard/DashboardStatsService.php:260-285 — atalhos dependem só de permissão,
// não de o módulo estar instalado.
public function getQuickActions()
{
    $user = Sentinel::getUser();
    if (!$user) { return []; }

    $actions = [];

    if ($this->canWrite($user, 'Noticias')) {
        $actions[] = [
            'icon' => 'bi-plus-circle',
            'label' => 'Nova Notícia',
            'url' => route('v2.noticias.cms.create', [], false),
            'color' => 'primary',
        ];
    }
    // ... 5 blocos equivalentes
```
`route()` lança `InvalidArgumentException("Route [{$name}] not defined.")` se a rota não existir (`vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:299-306`; `UrlGenerationException` é para parâmetros faltantes em rota existente); como o bloco não tem `try/catch` nem `class_exists`, um papel com permissão de escrita e módulo ausente derruba a tela.

```php
// V2/Services/Dashboard/DashboardStatsService.php:374-390 — saúde do Redis tratada como
// indicador, não como exceção (método, porém, sem chamador).
try {
    Cache::store('redis')->has('dashboard-health-check');
    $health[] = ['icon' => 'bi-lightning-charge', 'label' => 'Cache (Redis)',
                 'value' => 'Operacional', 'status' => 'success'];
} catch (\Exception $e) {
    $health[] = ['icon' => 'bi-lightning-charge', 'label' => 'Cache (Redis)',
                 'value' => 'Indisponível', 'status' => 'danger'];
}
```
O nome da chave (`dashboard-health-check`) só existe para forçar um round-trip ao Redis; nada a escreve.

### 2.4 Algoritmos e custos

| Operação | Complexidade | Observação |
|---|---|---|
| `getContentStats` | O(nº de módulos checados) = 8 blocos, 10 `COUNT` | cada `COUNT` sem filtro de índice específico; flags resolvidas 1 vez |
| `getQuickActions` | 6 lookups de papel | `PermissionService::getUserPermissions` **não** cacheia (`PermissionService.php:71-105`) |
| `getSeoRanking` | agregação `GROUP BY url_path` com ~59 predicados `NOT LIKE` | índice `url_path` existe (`V2/Services/SEO/Migrations/2026_06_29_093500_create_seo_tables.php:24-40`), mas a agregação tende a varrer; `limit` só corta a saída |
| `getTreemapData` | repete a agregação + O(n) para montar a árvore | `limit` default 50, controller usa 15 |
| `getRecentActivity` | 1 consulta + eager load `causer` | sem N+1; sem consumidor |
| `getSystemHealth` | 2 consultas + 1 round-trip Redis | sem consumidor |
| `getUserWelcome` | 1–2 consultas | `$user->roles` é lazy |

## 3. Particularidades

### 3.1 O que é exclusivo deste serviço

- É o **único agregador de métricas** do CMS: não existe repositório de estatísticas, tabela materializada ou endpoint de API de dashboard.
- É o único consumidor de `SEOService::getPageRanking` no CMS, e o único ponto que transforma `url_path` em hierarquia por módulo (via `resolveModuleName`).
- Concentra a dependência cruzada de **7 módulos de negócio** em um único arquivo; nenhum outro serviço referencia tantos models.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-DASHBOARD-01 | Card só aparece com `class_exists` **e** flag `active` | módulo ausente/desativado não gera erro nem card | `DashboardStatsService.php:57-59` e equivalentes |
| RN-DASHBOARD-02 | Toda query de card é envolvida em `try/catch` mudo | erro de schema esconde a métrica | `:60-72` |
| RN-DASHBOARD-03 | Card “Páginas Indexadas” ignora flags e permissões | todo usuário vê o total do índice | `:184-198` |
| RN-DASHBOARD-04 | Atalhos usam `route(..., [], false)` (URL relativa) | funciona sob subdiretório; mas quebra se a rota não existir | `:273,282,291,300,309,319` |
| RN-DASHBOARD-05 | Ranking e treemap compartilham a mesma fonte (`v2_seo_page_visits`) e o mesmo enriquecimento | os dois podem divergir em `limit` (10 vs 15) | `:37,41,502,532` |
| RN-DASHBOARD-06 | `getRecentActivity` e `getSystemHealth` são públicos e **sem consumidor** | código morto com custo de manutenção | `:213,335`; controller `:36-42` |
| RN-DASHBOARD-07 | `getTreemapData` chama `getSeoRanking` internamente | 2 agregações idênticas por request | `:532` |
| RN-DASHBOARD-08 | `resolveModuleName` usa `$row->module` do índice público; fallback = 1º segmento da URL | URL sem módulo indexado vira `Ucfirst(segmento)` | `:582-597` |

### 3.3 Casos-limite

| Caso | Comportamento | Fonte |
|---|---|---|
| Nenhum usuário na sessão | `getQuickActions()` = `[]`, `getUserWelcome()` = “Visitante” | `:262-265,455-464` |
| Todas as flags desligadas | `contentStats = []`; grade some; dashboard ainda `200` | `:52-54,200`; view `:47-66` |
| `v2_module_flags` ausente | `getAllFlags()` devolve `[]` e `isActive` cai no fallback (provável `false`) | `ModuleFlagService.php:57-65,72-100` |
| Tabela de um módulo renomeada | card desaparece sem erro | `try/catch` mudo |
| `v2_seo_page_visits` vazia | ranking `[]`, treemap `[]`, tabela mostra “Nenhum acesso registrado.” | `:533-540`; view `:272-278` |
| Todas as visitas são de bots | ranking existe; treemap soma `total_visits` (inclui bots) | `:537` |
| `total_visits` soma 0 | treemap `[]` (divisão por zero evitada) | `:537-540` |
| Permissão de escrita em módulo com rota inexistente | `InvalidArgumentException` → 500 | `:273`; `vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:299-306` |
| Redis indisponível | só afetaria `getSystemHealth`, que não é chamado | `:374-390` |
| Usuário `super-admin`/suporte | `hasPermission` retorna `true` sem consultar `v2_roles` | `PermissionService.php:46-49` |

### 3.4 Armadilhas de reimplementação

1. **Não troque `count()` por `->get()->count()`**: são 9 consultas de agregação baratas; materializar linhas mudaria a ordem de magnitude.
2. **`Evento::published()` não é `is_active`**: usa `published_at NOT NULL AND <= now()` (`Evento.php:260-264`). Um evento ativo sem `published_at` não conta em “Eventos Futuros”.
3. **`MensagemContato::inbox()` = `is_archived = false`**, não “não lida”; o card mostra não lidas com sublabel do total não arquivado.
4. **`JobOpening::open()` já exclui vagas vencidas** (`closes_at >= now()`); somar `published` mudaria o número.
5. **A ordem dos cards é a ordem do código** — não há `sort_order`; reordenar exige editar o serviço.
6. **`route()` sem verificação de existência** é o único ponto que pode derrubar o dashboard; na reimplementação, use `Route::has()` antes.
7. **Duas chamadas ao ranking** dobram o custo da tela mais visitada do CMS; memoizar por request é a correção óbvia.
8. **Treemap depende de `pct`** calculado em PHP (`round(($value / $total) * 100, 1)`); o JS só formata — não recalcular no cliente.

### 3.5 Dívidas e divergências

| Item | Situação | Fonte |
|---|---|---|
| `getRecentActivity` | documentado na view como “Atividade recente” (`aria-label`), mas o bloco da tabela é o **SEO Ranking**; o método nunca é chamado | `dashboard.blade.php:252-256`; controller `:36-42` |
| `getSystemHealth` | implementado, sem chamador | `:335-449` |
| `resolveSubjectLabel` / `getActionLabel` / `getActionIcon` | só usados por `getRecentActivity` → mortos na prática | `:654-744` |
| `avatar` | sempre `null` (“Placeholder para futura implementação”) | `:498` |
| `Cache` | importado e usado apenas em `getSystemHealth` | `:5,376` |

## 4. Integrabilidade

### 4.1 API pública

| Símbolo | Assinatura | Retorno | Exceções | Consumidores |
|---|---|---|---|---|
| `__construct` | `(ModuleFlagService $moduleFlag, PermissionService $permission)` | — | `BindingResolutionException` se o container não resolver | container (`DashboardCmsController.php:24`) |
| `getContentStats` | `(): array` | lista de `{icon,count,label,color,sublabel?}` | nenhuma (engole `Exception`) | controller (`:38`) |
| `getRecentActivity` | `($limit = 10): array` | lista de `{icon,causer,action,subject,timeAgo,createdAt}` | nenhuma | **nenhum** |
| `getQuickActions` | `(): array` | lista de `{icon,label,url,color}` | `InvalidArgumentException` via `route()` (`UrlGenerator.php:299-306`) | controller (`:39`) |
| `getSystemHealth` | `(): array` | lista de `{icon,label,value,status}` | nenhuma | **nenhum** |
| `getUserWelcome` | `(): array` | `{name,email,lastLogin,roles,avatar}` | nenhuma | controller (`:40`) |
| `getSeoRanking` | `($limit = 10): Collection` | linhas de `v2_seo_page_visits` (+`module`,`page_title`) | nenhuma | controller (`:37`), `getTreemapData` |
| `getTreemapData` | `($limit = 50): array` | `['name'=>'Acessos por Página','children'=>[...]]` ou `[]` | nenhuma | controller (`:41`) |

### 4.2 Componentes Blade e contrato de UI

O serviço não tem views próprias; a tela é `v2-cms::dashboard` (`V2/layouts/cms/dashboard.blade.php`), que consome as 5 variáveis passadas pelo controller:

| Seção | Variável | Contrato |
|---|---|---|
| Barra de boas-vindas | `$userWelcome` | usa `name`, `lastLogin`, `roles`, `email`; avatar é a inicial de `name` |
| Atalhos | `$quickActions` | `@foreach` com `icon`, `url`, `color`, `label` |
| Grade de cards | `$contentStats` | `icon`, `count`, `label`, `color`, `sublabel` (opcional) |
| Treemap | `$treemapData` | exige `children`; injetado com `@json($treemapData)` e renderizado por ECharts (`echarts.init`, `assets/vendor/echarts/echarts.min.js`) |
| Ranking | `$seoRanking` | colunas `url_path`, `total_visits`, `bot_visits`, `module` (opcional) |

Não há `data-*` de contrato nem evento JS emitido; o treemap é somente leitura (`nodeClick: false`, `breadcrumb.show=false`).

### 4.3 JS e ciclo de vida

`dashboard.blade.php:100-230` inicializa o ECharts em `DOMContentLoaded`, no elemento `#treemap-chart`; se o container não existir, retorna. Sem JS, o treemap não aparece (o `<canvas>` fica vazio), mas cards, atalhos e ranking continuam renderizados no servidor. Não há teardown nem `resize` handler registrado no trecho lido (`[I]` — o gráfico não se adapta a resize de janela até novo load).

### 4.4 Ordem de inicialização e registro

1. **Nenhum registro**: não há provider, facade, singleton ou migration do serviço; `DashboardStatsService` é resolvido por autowiring.
2. Precisa de `ModuleFlagService` e `PermissionService` já ligados (ambos são classes concretas com dependências de `DB`/`Sentinel`).
3. Precisa das migrations dos módulos consultados (`v2_news`, `v2_eventos`, `v2_contato_mensagens`, `v2_job_openings`, `v2_job_applications`, `v2_membership_leads`, `v2_parceiros_institucionais`) e de `v2_public_search_index`/`v2_seo_page_visits` para os cards 8 e o ranking.
4. A rota `/cms` roda sob `web` + `CmsAuthenticate` (`V2/Core/Routes/cms.php:20-23`), portanto o serviço sempre executa com usuário autenticado.
5. `Sentinel::getUser()` é chamado em `getQuickActions` e `getUserWelcome`; se o guard não estiver inicializado, ambos degradam para vazio/“Visitante”.

### 4.5 Receitas de uso

**1. Render canônico (o único existente):**

```php
$data = [
    'seoRanking'   => $this->stats->getSeoRanking(10),
    'contentStats' => $this->stats->getContentStats(),
    'quickActions' => $this->stats->getQuickActions(),
    'userWelcome'  => $this->stats->getUserWelcome(),
    'treemapData'  => $this->stats->getTreemapData(15),
];
return view('v2-cms::dashboard', $data);
```

**2. Adicionar uma métrica nova (padrão do arquivo):**

```php
if (class_exists(\V2\Modules\Meu\Modulo\Models\Coisa::class)
    && $this->moduleFlag->isActive('MeuModulo')) {
    try {
        $stats[] = [
            'icon' => 'bi-box', 'count' => Coisa::pending()->count(),
            'label' => 'Coisas Pendentes', 'color' => 'warning', 'sublabel' => 'aguardando',
        ];
    } catch (\Exception $e) { /* Silencia */ }
}
```

**3. Consumir a saúde do sistema (hoje não usado):**

```php
$health = app(\V2\Services\Dashboard\DashboardStatsService::class)->getSystemHealth();
// [['icon'=>'bi-gear','label'=>'Ambiente','value'=>'Production','status'=>'success'], ...]
```

### 4.6 Anti-uso

1. **Chamar `getTreemapData()` e `getSeoRanking()` com o mesmo limite em um widget novo** sem memoizar: cada chamada repete a agregação de `v2_seo_page_visits` com ~59 `NOT LIKE`.
2. **Adicionar um card sem `class_exists` + flag**: em instalação sem o módulo, o dashboard passa a 500 (o `try/catch` não protege `class_exists` de model inexistente usado em `use`).
3. **Usar `getContentStats()` como fonte de verdade de contagem**: números são por tabela bruta, sem escopo regional e sem excluir órfãos; relatórios oficiais devem consultar os módulos.

### 4.7 Custos e limites

| Limite | Valor | Fonte |
|---|---|---|
| Ranking no dashboard | 10 | controller `:37` |
| Ranking no treemap | 15 | controller `:41` |
| `getRecentActivity` | 10 (default) | `:213` |
| `getTreemapData` | 50 (default, não usado pelo controller) | `:530` |
| Cache | nenhum no serviço; `/cms` fora do cache de página | `PageCacheMiddleware.php:16-23` |
| Timeout/throttle | nenhum específico | rota `cms.php:22` |
| Prefixos excluídos do ranking | 17 prefixos → 34 condições (`not like '<p>/%'` + `!= '<p>'`); lista literal mantida por `03_servicos-ui/seo/spec.md` | `SEOService.php:203-207,220-224` |
| Extensões excluídas do ranking | 35 (`not like '%.<ext>'`); lista literal mantida por `03_servicos-ui/seo/spec.md` | `SEOService.php:209-216,226-228` |

## 5. Rotas e endpoints

| Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|
| GET | `/cms` | `v2.cms.dashboard` | `web`, `CmsAuthenticate` | `DashboardCmsController@index` | autenticado (qualquer papel; cards não filtram RBAC) | Renderiza o dashboard com 5 datasets | `V2/Core/Routes/cms.php:22` |
| GET | `/cms/dashboard` | — (`Route::redirect`) | `web`, `CmsAuthenticate` | redirect para `/cms` | idem | Redireciona legado | `V2/Core/Routes/cms.php:23` |

Não há endpoint JSON nem API de estatísticas.

## 6. Dados e migrations

O serviço **não cria tabelas**. Lê:

| Tabela | Colunas usadas | Origem (unidade) |
|---|---|---|
| `v2_news` | `status`, `published_at` | MOD-PUBLICACOES-NOTICIAS |
| `v2_eventos` | `published_at`, `event_date_start` | MOD-PUBLICACOES-AGENDA |
| `v2_contato_mensagens` (`MensagemContato`) | `is_read`, `is_archived` | MOD-INSTITUCIONAL-FALECONOSCO |
| `v2_job_openings` | `is_active`, `closes_at` | MOD-PUBLICACOES-TRABALHECONOSCO |
| `v2_job_applications` | `status` | idem |
| `v2_membership_leads` | `status` | MOD-INSTITUCIONAL-ASSOCIESE |
| `v2_parceiros_institucionais` | `is_active` | MOD-CONEXOES-PARCEIROSINSTITUCIONAIS |
| `v2_public_search_index` | `count(*)`, `slug`, `module`, `title` | SVC-SEARCH |
| `v2_seo_page_visits` | `url_path`, `is_bot` | SVC-SEO |
| `v2_module_flags` | `module_key`, `active`, `deleted_at` | CORE |
| `v2_roles` | `permissions_v2` | CORE-AUTH |
| `v2_system_controls`, `v2_mail_logs`, `activity_log` | `control_key/control_value`, `status/error_message/created_at`, `description/subject_type/causer` | CORE / SVC-ACTIVITYLOGGER (só nos métodos mortos) |

Os nomes físicos de `MensagemContato`, `JobApplication`, `MembershipLead` devem ser confirmados no `$table` de cada model ([I] — os scopes foram lidos, o `$table` não faz parte desta unidade).

## 7. Configuração e permissões

| Chave | Default | Efeito | Fonte |
|---|---|---|---|
| `app.env` | `production` | rótulo/estado do indicador “Ambiente” (método morto) | `:340-346` |
| `app.debug` | `false` | indicador “Debug Mode” (método morto) | `:413-419` |

Permissões: a rota exige apenas `CmsAuthenticate`. Os cards **não** verificam permissão; os atalhos verificam `PermissionService::hasPermission($user, $moduleKey, 'write'|'read')` com as chaves `Noticias`, `Agenda`, `FaleConosco`, `BasicPage`, `GestaoPermissoes`, `MailAudit` (`:269-331`). `super-admin` e `config('v2.support.email')` recebem todos os atalhos (`PermissionService.php:46-49`).

## 8. Observabilidade e erros

| Situação | Efeito | Fonte |
|---|---|---|
| Falha em qualquer `count()` de card | card omitido; nenhum log | `:70-72` e equivalentes |
| Falha em `getSeoRanking` | `collect()`; ranking e treemap vazios | `:504-509` |
| Falha em `getUserWelcome` | não há `try/catch`; `$user->roles` pode lançar e derrubar a tela | `:453-499` |
| Falha em `getQuickActions` | `InvalidArgumentException` → 500 | `:273`; `UrlGenerator.php:299-306` |
| Redis/BD indisponível em `getSystemHealth` | `status=danger`; sem exceção | `:335-449` |
| Nenhum log, métrica ou auditoria | — | ausência de `Log::` no arquivo |

## 9. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| `DashboardCmsController@index` | 5 dos 7 métodos públicos | injeção no construtor | renomear/alterar retorno quebra `/cms` |
| `v2-cms::dashboard` | os 5 datasets | variáveis Blade | remover `sublabel`/`pct` quebra formatação |
| `SEOService` | — (é dependência, não consumidor) | `getPageRanking` | mudar assinatura quebra o card e o treemap |
| `PermissionService` / `ModuleFlagService` | — | dependências de construtor | mudar assinatura quebra a injeção |

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-DASHBOARD-01 | Não há cache nem materialização das métricas | custo por request na tela mais acessada do CMS | `DashboardStatsService.php` (ausência de `Cache::remember`) |
| LG-DASHBOARD-02 | `getRecentActivity` e `getSystemHealth` sem chamador | funcionalidade anunciada e não entregue | controller `:36-42`; view `:253-258` |
| LG-DASHBOARD-03 | Sem escopo regional nas contagens | operador regional vê números globais | ausência de `RegionalScopeService` no arquivo |
| LG-DASHBOARD-04 | `[L]` nomes físicos de três tabelas não confirmados nesta unidade | risco de erro no DDL da reimplementação | `$table` de `MensagemContato`, `JobApplication`, `MembershipLead` |
| LG-DASHBOARD-05 | Sem testes do serviço | regressões de contagem passam despercebidas | `sucesu-cms/tests/` |

## 11. Checklist de paridade

- [ ] `PC-DASHBOARD-01` — `GET /cms` autenticado retorna `200` e a view `v2-cms::dashboard`. Fonte: `cms.php:22`; controller `:44`.
- [ ] `PC-DASHBOARD-02` — com `Noticias` ativo, o card “Notícias Publicadas” mostra o mesmo valor de `News::published()->count()` e o sublabel de rascunhos. Fonte: `:61-68`.
- [ ] `PC-DASHBOARD-03` — desativar uma flag em `v2_module_flags` remove o card correspondente sem erro. Fonte: `:57-59`; `ModuleFlagService.php:72-100`.
- [ ] `PC-DASHBOARD-04` — “Eventos Futuros” só conta `published_at <= now()` e `event_date_start >= hoje`. Fonte: `:80`; `Evento.php:260-273`.
- [ ] `PC-DASHBOARD-05` — “Páginas Indexadas” é igual a `SELECT count(*) FROM v2_public_search_index`. Fonte: `:187`.
- [ ] `PC-DASHBOARD-06` — atalhos aparecem/desaparecem conforme `PermissionService::hasPermission` por módulo. Fonte: `:269-331`.
- [ ] `PC-DASHBOARD-07` — sem usuário, `userWelcome.name = 'Visitante'` e `roles = '—'`. Fonte: `:455-464`.
- [ ] `PC-DASHBOARD-08` — `getTreemapData` produz `children` com `pct` por página e por módulo, e `[]` quando não há visitas. Fonte: `:530-583`.
- [ ] `PC-DASHBOARD-09` — `getRecentActivity()` e `getSystemHealth()` existem e não são chamados pelo controller. Fonte: controller `:36-42`.

## 12. Evidências consultadas

| Arquivo | Faixas |
|---|---|
| `V2/Services/Dashboard/DashboardStatsService.php` | 1-744 (integral) |
| `V2/Core/Controllers/DashboardCmsController.php` | 1-46 |
| `V2/layouts/cms/dashboard.blade.php` | 1-300 |
| `V2/Core/Routes/cms.php` | 1-40 |
| `V2/Core/Services/ModuleFlagService.php` | 1-115 |
| `V2/Core/Services/PermissionService.php` | 40-105 |
| `V2/Services/SEO/Services/SEOService.php` | 200-276 |
| `V2/Services/SEO/Migrations/2026_06_29_093500_create_seo_tables.php` | 20-50 |
| `V2/Core/Middleware/PageCacheMiddleware.php` | 1-90 |
| Models: `News.php:400-403`, `Evento.php:260-273`, `MensagemContato.php:87-99`, `JobOpening.php:232-238`, `JobApplication.php:126-129`, `MembershipLead.php:105-108` | — |
