---
id: MOD-INSTITUCIONAL-SOBRENOS
tipo: modulo
onda: 5
status: concluido
unidades: [MOD-INSTITUCIONAL-SOBRENOS]
fontes:
  - V2/Modules/Institucional/SobreNos/
depende_de: [CORE-BOOT, CORE-AUTH, CORE-RUNTIME, SVC-LANDINGSECTIONSEDITOR, SVC-MEDIA, SVC-GALLERY, SVC-CONTENTRICHEDITOR, SVC-SEO, SVC-ACTIVITYLOGGER, MOD-SUPPORT-STRUCTURALLANDINGEDITOR]
consumidores: [UI-CMS, UI-PORTAL, SVC-SEO, SVC-SEARCH, SVC-DASHBOARD, MOD-CONEXOES-ASSOCIADOS]
criticidade: media
dono: SobreNos
---

# Institucional/SobreNos

> Página institucional `/sobre-nos` montada a partir de **um único payload JSON** em
> `v2_layout_base_configs` (`key='sobre_nos_sections'`), renderizado por cinco widgets
> `sobre-nos-*` registrados no `WidgetRegistry` do LandingSectionsEditor. A seção da
> diretoria não guarda pessoas no JSON: ela é hidratada em tempo de render a partir do
> **biênio corrente** (`v2_bienniums.is_current`) e dos membros ativos
> (`v2_board_members`), que têm CRUD próprio, foto na Galeria, bio em AST e página
> individual em `/equipe/{slug}`. Os três contadores da página são recalculados por
> heurística de rótulo (associados, anos desde 1965, empresas impactadas por estado),
> ignorando o valor digitado no CMS.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Publica `/sobre-nos` com as seções do payload na ordem salva, injeta os membros ativos do biênio corrente na seção `sobre-nos-team`, recalcula os contadores por rótulo e publica `/equipe/{slug}` por membro; o CMS edita as seções, os membros e os biênios. |
| Entrada | `GET /sobre-nos`, `GET /equipe/{slug}`, `GET /diretoria`, `GET /diretoria/{slug}`; 11 endpoints `cms/institucional/sobre-nos*`; campos `landing_sections_json`, `biennium_id`, `name`, `position`, `description`, `bio`, `photo_id`, `linkedin_url`, `email`, `sort_order`, `is_active`, `label`, `start_year`, `is_current`. |
| Saída | HTML `v2-sobrenos::cms.editor`, `v2-sobrenos::cms.members.{index,create,edit}`, `v2-sobrenos::portal.index`, `v2-sobrenos::portal.member.show`; 5 pares de views LSE (`lse.forms.*` / `lse.render.*`); `302` com flash; `301` para `/sobre-nos#diretoria` e `/equipe/{slug}`; linhas em `v2_layout_base_configs`, `v2_board_members`, `v2_bienniums`, `activity_log`, índices de busca. |
| Efeitos colaterais | `activity_log` (created/updated/deleted em seções, membros e biênios); exclusão best-effort de `page:*` no Redis; reindexação em `v2_cms_search_index` (`diretoria`) e `v2_public_search_index` (`board_member`); remoção desses índices no soft delete; `v2_seo_page_visits` (composer global de SEO). |
| Particularidade central | O conteúdo editorial e o conteúdo transacional vivem em lugares diferentes: texto/imagens no JSON; diretoria em tabelas. A seção de diretoria **ignora** qualquer membro cadastrado no JSON e só mostra o biênio corrente; e o widget de contadores **sobrescreve** os valores salvos conforme o texto do rótulo. |
| Como se integra | Namespace de views `v2-sobrenos` (`strtolower('SobreNos')`, `V2ServiceProvider.php:123`); `ModuleProvider` registra os 5 widgets no `WidgetRegistry` no construtor; consome `SVC-LANDINGSECTIONSEDITOR` (payload/render), `SVC-MEDIA` (`picture`, `media-input`), `SVC-CONTENTRICHEDITOR` (bio), `SVC-GALLERY` (`v2_gallery_media`), `SVC-SEO`, `SVC-ACTIVITYLOGGER`, `MOD-CONEXOES-ASSOCIADOS`/`UNIDADEREGIONAL` (contadores). |
| Se removido, quebra | `/sobre-nos` cai no fallback `V2/Core/Routes/fallback.php` (BasicPage) e responde 404 se não houver página runtime com esse slug; `/equipe/*` e os 301 de `/diretoria*` desaparecem; o menu CMS perde o item posição 4 com as abas Seções/Membros; o sitemap perde `/diretoria` e `/diretoria/{slug}`; a busca pública perde os resultados `diretoria`/`board_member`; o Dashboard perde os rótulos “Membro da Diretoria” e “Bienio”. |
| Superfície pública | `SobreNosSectionNormalizer::ensureIds`, `SobreNosDataIntegrator::integrate`, `EmpresasImpactadasCalculator::{getAssociadosPorEstado,calcularImpactoTotal,getValorArredondado,getDetalhamento}`, `Biennium::{scopeCurrent,members}`, `BoardMember::{scopeActive,scopeOrdered,scopeBySlug,getPhotoUrl,getPhotoFullUrl,getHtmlBio,syncToSearch}`, 5 widgets LSE, 4 controllers de ação, 11 rotas CMS + 4 rotas públicas. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

- **Visitante**: abrir `/sobre-nos` e ver, na ordem salva, as seções `about`, `team`,
  `video`, `counters` (a seção `testimonial` é deliberadamente omitida no portal); clicar
  em “Conheça a Diretoria” (CTA para `#diretoria`) e em cada card da diretoria, que leva
  a `/equipe/{slug}` (`SobreNosPortalController.php:33-35,95-104`).
- **Visitante**: abrir a página individual do membro com foto responsiva (variantes WebP),
  cargo, biênio, descrição curta, biografia rica renderizada do AST, LinkedIn e e-mail
  (`SobreNosPortalController.php:112-148`).
- **Operador CMS com `write` em `SobreNos`**: editar as seções no editor LSE (adicionar,
  remover, reordenar e mudar parâmetros de qualquer widget, inclusive os padrão) e salvar
  de uma vez o JSON inteiro (`SobreNosCmsController.php:54-87`).
- **Operador CMS com `write`**: listar membros paginados de 15 em 15, criar, editar
  (nome, cargo, biênio, LinkedIn, e-mail, descrição, bio rica, foto) e remover (soft
  delete) (`BoardMemberController.php:36-154`).
- **Operador CMS com `write`**: criar, renomear, trocar o ano e marcar o biênio corrente
  num modal da mesma tela de membros (`BoardMemberController.php:162-249`).
- **Operador com apenas `read`**: vê todas as telas, mas os botões de escrita ficam
  escondidos por `@v2can('SobreNos','write')` e qualquer POST/PUT/DELETE é bloqueado no
  middleware com “Operação negada. Seu perfil possui apenas permissão de leitura para o
  módulo: Sobre Nós”.

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| Cria ou atualiza a linha `v2_layout_base_configs` com `key='sobre_nos_sections'` e o payload reindexado; se estava soft-deleted, restaura | `saveSections` | `SobreNosCmsController.php:66-74` |
| Regenera `id` (UUID v4) para seção sem id e reescreve `order=1..n` em **todas** as seções | `SobreNosSectionNormalizer::ensureIds` | `SobreNosCmsController.php:65-66`; `Services/SobreNosSectionNormalizer.php:18-36` |
| Grava `activity_log` com `old`/`new` completos do payload (`type=sobre_nos_sections_save`) | `ActivityLoggerService::log` | `SobreNosCmsController.php:78-82` |
| Apaga as chaves Redis `<cache.prefix>:page:*` (cache de página de 5 min) | `clearPageCache` | `SobreNosCmsController.php:94-106`; `V2/Core/Middleware/PageCacheMiddleware.php:11-14,88-91` |
| Insere membro com `id=v2_uuid()`, slug derivado do nome, `photo` espelhando `GalleryMedia.file_path`, `created_by` do usuário Sentinel | `store` → `fillMember` → `resolvePhotoFromMedia` | `BoardMemberController.php:68-88,277-324` |
| Atualiza membro; **sem** `sort_order`/`is_active` no POST, grava `0` e `true` | `update` → `fillMember` | `BoardMemberController.php:112-128,286-289` |
| Soft delete de membro (`deleted_at`), mantendo a linha e a mídia | `destroy` | `BoardMemberController.php:136-154`; `Models/BoardMember.php:35` |
| Reindexa o membro no CMS (`diretoria`) e no público (`board_member`) a cada save; remove do público quando `is_active=false` | model `saved` → `syncToSearch` | `Models/BoardMember.php:101-107,218-257` |
| Remove o membro dos dois índices no delete | model `deleted` | `Models/BoardMember.php:109-116` |
| Insere/atualiza biênio com `end_year = start_year + 1` e exclusividade de `is_current` | `storeBiennium` / `updateBiennium` | `BoardMemberController.php:162-224` |
| Soft delete de biênio **sem** tocar nos membros | `destroyBiennium` | `BoardMemberController.php:232-249` |
| Hidrata a seção `sobre-nos-team` com `biennium_label` + `members[]` do biênio corrente | `SobreNosDataIntegrator::integrate` | `Services/SobreNosDataIntegrator.php:20-73` |
| Sobrescreve `counters.*.value` por rótulo e resolve ícones/variantes | `SobreNosCountersWidget::prepare` + renderer | `LseWidgets/Widgets/SobreNosCountersWidget.php:46-79` |
| Cada resposta `/sobre-nos` e `/equipe/*` registra uma visita em `v2_seo_page_visits` (buffer de 50) | `SEOService::trackAccess` via composer | `V2/Services/SEO/Providers/SEOServiceProvider.php:23-56`; `V2/Services/SEO/Services/SEOService.php:121-166` |
| Redireciona 301 `/diretoria` → `/sobre-nos#diretoria` e `/diretoria/{slug}` → `/equipe/{slug}` | `redirectOldDiretoria`/`redirectMember` | `SobreNosPortalController.php:155-169` |

### 1.3 Contrato observável (entrada → saída)

| Entrada | Resultado |
|---|---|
| `GET /sobre-nos` | `200` HTML. Payload ausente ou `[]` → alerta “Conteúdo da página "Sobre Nós" ainda não configurado.” (`Views/portal/index.blade.php:29-36`). Resposta cacheável 5 min (`page.cache`) com `X-Cache-Status: HIT/MISS` e `Cache-Control: public, max-age=60, must-revalidate` mais `Vary: Accept-Encoding` (`PublicCacheMiddleware.php:81-87`). |
| `GET /sobre-nos` com seção `sobre-nos-testimonial` no payload | A seção é descartada antes da integração/renderização; nenhum HTML dela é emitido (`SobreNosPortalController.php:33-35,95-104`). |
| `GET /sobre-nos` sem biênio `is_current` | Seção team renderiza “Em breve, conheça nossa diretoria.” (o `biennium_label` não é injetado) (`Services/SobreNosDataIntegrator.php:46-66`; `Views/lse/render/team.blade.php:47-49`). |
| `GET /equipe/{slug}` existente, `is_active=true` | `200` HTML com foto, cargo, biênio, descrição, bio e links (`SobreNosPortalController.php:112-148`). |
| `GET /equipe/{slug}` inexistente, inativo ou soft-deleted | `404` (`firstOrFail` sobre `active()->bySlug()`) (`:115-118`). |
| `GET /diretoria` | `301` para `/sobre-nos#diretoria` (`:155-158`). |
| `GET /diretoria/{slug}` | `301` para `/equipe/{slug}`, **sem** verificar se o membro existe (`:166-169`). |
| `POST /cms/institucional/sobre-nos/salvar` sem `landing_sections_json` | `302` de volta com erro de validação (chave `validation.required`, sem tradução) (`SobreNosCmsController.php:56-58`). |
| `POST .../salvar` com `landing_sections_json` não-JSON ou JSON escalar | `302` para a própria tela, flash “Seções da página "Sobre Nós" salvas com sucesso!” e payload gravado como `[]` (apaga tudo) (`:60-63,84-86`). |
| `POST .../salvar` válido | `302` para `v2.sobrenos.cms.sections` com o flash acima; `activity_log` criado (`:76-86`). |
| `POST .../members/novo` válido | `302` para `v2.sobrenos.cms.members`; flash `created_item = {type:"Membro", title:<nome>, route:<index>}` (`BoardMemberController.php:81-87`). |
| `POST .../members/novo` sem `name` | `302` de volta; mensagem “O nome é obrigatório.” (`Requests/BoardMemberRequest.php:44`). |
| `POST .../members/novo` com `biennium_id` inexistente | `302`; “O biênio selecionado é inválido.” (`:42-43`). |
| `PUT`/`POST .../members/editar/{id}` válido | `302` para o índice; “Membro atualizado com sucesso!” (`BoardMemberController.php:123-127`). |
| `DELETE .../members/{id}` | `302` para o índice; “Membro "<nome>" removido com sucesso!” (`:149-153`). |
| Qualquer ação CMS com id inexistente | `404` (`findOrFail`). |
| `POST .../bienniums/store` válido | `302` para membros; flash “Biênio "<label>" criado com sucesso.” (via `created_item`) (`:183-189`; `Views/cms/members/index.blade.php:23-30`). |
| `PUT`/`POST .../bienniums/update/{id}` | `302`; “Biênio atualizado com sucesso!” (`:221-223`). |
| `DELETE .../bienniums/{id}` | `302`; “Biênio "<label>" removido com sucesso!” (`:246-248`). |
| Rota CMS sem sessão Sentinel | `302` para `v2.cms.login` ou `401 Unauthorized.` em AJAX (`V2/Core/Middleware/CmsAuthenticate.php:18-42`). |
| Rota CMS com usuário sem papéis | Logout forçado + `302` login com erro “Acesso negado. Seu usuário não possui permissões administrativas.” (`:29-42`). |
| Rota CMS com módulo `SobreNos` desativado em `v2_module_flags` | `404` “Modulo desativado.” (prefixo ≠ `cms`, então não redireciona) (`V2/Core/Middleware/ModuleAccessMiddleware.php:46-63`). |

### 1.4 O que este módulo NÃO faz

- Não grava as seções em tabela própria nem versiona o payload: só há o JSON + o log de
  atividade (`LayoutBaseConfig` é do módulo `Support/StructuralLandingEditor`).
- Não publica/despublica a página: não há `is_active`, rascunho ou agendamento; salvar já
  vale para o portal.
- Não valida os parâmetros das seções no servidor ao salvar: `saveSections` só exige que
  `landing_sections_json` seja string. As regras dos widgets são usadas apenas pelo
  endpoint `/cms/v2/landing-sections/validate`, chamado pelo JS do editor
  (`V2/Services/LandingSectionsEditor/Controllers/LandingSectionsEditorController.php:103-120`).
- Não sanitiza o payload salvo: `sanitizeContent` só é aplicado no preview do editor
  (`LandingSectionsEditorController.php:149-153`), não no `saveSections`.
- Não exclui fisicamente membros, biênios, mídias nem o payload (`SoftDeletes` em todos).
- Não reordena membros por interface: `sort_order` não tem campo no formulário; a lista
  do CMS ordena por ele, mas sem editor dedicado.
- Não filtra a listagem de membros por biênio na UI: o controller aceita `?biennium_id=`,
  porém a view não oferece o filtro (`BoardMemberController.php:40-42`; `Views/cms/members/index.blade.php:9-18`).
- Não valida unicidade de slug: dois membros homônimos geram o mesmo slug e
  `/equipe/{slug}` resolve sempre a primeira linha (`Models/BoardMember.php:90-99`).
- Não renderiza o depoimento (`sobre-nos-testimonial`) no portal, embora o seeder o crie
  e o CMS permita editá-lo (`SobreNosPortalController.php:33-35`).
- Não alimenta o sitemap com `/sobre-nos` nem com `/equipe/{slug}`: o sitemap publica
  `/diretoria` e `/diretoria/{slug}`, que são apenas 301 (`V2/Services/SEO/Controllers/SeoPortalController.php:295-323`).

## 2. Abordagem

### 2.1 Estratégia em uma página

Duas fontes de verdade, uma página: (a) **editorial** — um único JSON de seções em
`v2_layout_base_configs`, editado pelo LandingSectionsEditor com widgets registrados por
slug; (b) **transacional** — `v2_bienniums`/`v2_board_members`, editados em CRUD próprio.
O portal encadeia quatro passos fixos: filtrar tipos desabilitados → integrar dados de
domínio na seção team → preparar anexos/variantes pelos widgets → renderizar via
`WidgetRegistry`. Os contadores são o único ponto em que o dado transacional entra numa
seção sem passar pelo integrador: o próprio widget `sobre-nos-counters` recalcula valores
no `prepare()` a partir do rótulo.

```mermaid
flowchart LR
  subgraph CMS
    E[editor LSE] -->|landing_sections_json| S[saveSections]
    S --> N[ensureIds]
    N --> DB[(v2_layout_base_configs)]
    M[CRUD membros] --> BM[(v2_board_members)]
    B[modal biênios] --> BN[(v2_bienniums)]
  end
  DB --> P[portal /sobre-nos]
  P --> F[filterDisabledSections]
  F --> I[DataIntegrator: team]
  BM --> I
  BN --> I
  I --> R[Renderer::prepareSections]
  R --> W[Widget render views]
  W --> C[Counters: DB + JSON fatores]
  W --> H[HTML final]
  BM --> D[/equipe/slug/]
```

### 2.2 Mecânica passo a passo

1. **Boot**: `V2ServiceProvider` percorre `V2/Modules/<Grupo>/<Modulo>`, instancia
   `SobreNos\ModuleProvider` (que **registra os 5 widgets no construtor**), acrescenta
   `config['group']='Institucional'` ao `getConfig()`, carrega `Migrations/`, registra
   `Views/` no namespace `v2-sobrenos` e mapeia as rotas
   (`V2/Core/Providers/V2ServiceProvider.php:98-128`).
2. **Rotas CMS**: `Routes/cms.php` declara o grupo com `prefix='cms/institucional/sobre-nos'`
   e middleware `CmsAuthenticate`; o mapeador global acrescenta `web` +
   `module.active:SobreNos` (`Routes/cms.php:3-6`; `V2ServiceProvider.php:290-307`).
3. **Rotas públicas**: `Routes/web.php` é mapeado sob `web` + `MaintenanceMiddleware` +
   `page.cache` + `public.cache` (`Routes/web.php:3-19`; `V2ServiceProvider.php:296-302`).
4. **Editar seções**: `sections()` lê `LayoutBaseConfig::where('key','sobre_nos_sections')`,
   usa `payload` (ou `[]`) e busca o histórico no `ActivityLogger` dentro de `try/catch`
   (`SobreNosCmsController.php:31-46`).
5. **Salvar seções**: valida string → `json_decode` → `ensureIds` → `firstOrNew` com
   `withTrashed` (restaura se necessário) → grava → limpa cache → audita com `old`/`new` →
   flash + redirect (`:54-87`).
6. **Render público**: lê o payload, remove `sobre-nos-testimonial`, chama o integrador,
   chama `prepareSections` e só então passa `$sections` para a view
   (`SobreNosPortalController.php:60-86`).
7. **Integração da diretoria**: `integrate()` percorre as seções por referência e, para
   `type==='sobre-nos-team'`, busca o biênio `current()` com `members` filtrados por
   `active()->ordered()` e `photoMedia`, montando `biennium_label` + `members[]`; qualquer
   exceção é logada e resulta em `members=[]` (`Services/SobreNosDataIntegrator.php:20-73`).
8. **Preparação de anexos**: a view do portal inclui
   `v2-service-landingsectionseditor::components.landing-sections-viewer`, que chama
   **novamente** `prepareSections` antes de renderizar cada seção pelo `getRenderView()`
   do widget (`V2/Services/LandingSectionsEditor/Views/components/landing-sections-viewer.blade.php:1-48`).
   Resultado: `prepare()` dos widgets roda duas vezes por request (uma no controller, uma
   na view).
9. **Página do membro**: `show()` carrega `photoMedia` + `biennium`, monta `photoUrl`,
   decodifica `variants`, converte a bio em HTML pelo `ContentRichEditorRendererService`
   (com `Log::warning` em falha), define SEO e devolve a view (`SobreNosPortalController.php:112-148`).
10. **Criar/atualizar membro**: `BoardMemberRequest` valida; `fillMember` atribui campo a
    campo, garante `created_by`, resolve a foto (`photo_id` → `GalleryMedia.file_path`) e
    salva; o hook `saving` gera slug se vazio e o hook `saved` reindexa a busca
    (`BoardMemberController.php:277-324`; `Models/BoardMember.php:82-117`).
11. **Biênios**: `storeBiennium`/`updateBiennium` desmarcam o biênio corrente anterior
    quando o novo é marcado, derivam `end_year` e gravam; `destroyBiennium` só faz soft
    delete (`BoardMemberController.php:162-249`).

### 2.3 Decisões de design e por quê

- **Conteúdo em JSON, diretoria em tabelas** `[C]`: evita migração a cada mudança de
  layout e permite que a diretoria seja editada por quem não mexe em seções
  (`SobreNosPortalController.php:60-67`).
- **Registro de widgets no construtor do provider** `[C]`: como o provider é instanciado
  no boot de todo request, os tipos `sobre-nos-*` existem antes de qualquer chamada a
  `LandingSectionsEditorRendererService::getSectionTypes()` (`ModuleProvider.php:18-25`).
- **Prefixo `sobre-nos-` como marcador de “Personalizados”** `[C]`: o editor agrupa os
  tipos por prefixo (`['sucesu-', 'sobre-nos-']`) e os destaca no dropdown; o prefixo é,
  na prática, o registro visual do módulo (`V2/Services/LandingSectionsEditor/Views/components/forms/landing-sections-editor.blade.php:36-52`).
- **Normalização no servidor antes de salvar** `[C]`: o editor depende de `id` para editar
  e de `order` para reordenar; o `ensureIds` garante o invariante mesmo quando o payload
  veio de seeder legado (`Services/SobreNosSectionNormalizer.php:5-9`).
- **Heurística por rótulo nos contadores** `[I]`: permite que o CMS mantenha o layout e
  que os números sejam sempre reais sem uma tela de configuração; o custo é que renomear
  o rótulo muda o dado (`SobreNosCountersWidget.php:58-76`).
- **Cache de página limpo por padrão Redis** `[C]`: `page:*` é a chave do
  `PageCacheMiddleware`; a limpeza usa `KEYS` + `DEL` fora do cache store
  (`SobreNosCmsController.php:94-106`).

### 2.4 Trechos de código pivô

```php
// V2/Modules/Institucional/SobreNos/ModuleProvider.php:18-25
// Registro dos cinco tipos de seção no WidgetRegistry — acontece no boot.
public function __construct()
{
    WidgetRegistry::register('sobre-nos-about', new SobreNosAboutWidget());
    WidgetRegistry::register('sobre-nos-team', new SobreNosTeamWidget());
    WidgetRegistry::register('sobre-nos-video', new SobreNosVideoWidget());
    WidgetRegistry::register('sobre-nos-counters', new SobreNosCountersWidget());
    WidgetRegistry::register('sobre-nos-testimonial', new SobreNosTestimonialWidget());
}
```

> O registry é um array estático indexado pelo slug do `type`. É esse slug que o payload
> grava, que o editor usa para escolher o form (`getFormView`) e que o portal usa para
> escolher o render (`getRenderView`). Registrar em outro ponto (fora do boot) faz o tipo
> desaparecer do dropdown “Adicionar Seção”.

```php
// V2/Modules/Institucional/SobreNos/Services/SobreNosSectionNormalizer.php:18-36
// Invariante do payload: id presente + order denso 1..n.
public static function ensureIds(array $sections): array
{
    $normalized = [];
    foreach (array_values($sections) as $index => $section) {
        if (!is_array($section)) {
            continue;
        }
        if (empty($section['id'])) {
            $section['id'] = v2_uuid();
        }
        $section['order'] = $index + 1;
        $normalized[] = $section;
    }
    return $normalized;
}
```

> `order` é **sempre** reescrito, mesmo que o payload traga outra ordem; a ordem real é a
> posição no array. Entradas que não são array são silenciosamente descartadas — um
> payload corrompido perde seções sem erro. Sem `id`, o editor não consegue abrir a seção
> (form vazio) nem reordenar (`findIndex` falha) (`:5-9`).

```php
// V2/Modules/Institucional/SobreNos/Services/SobreNosDataIntegrator.php:42-63
$biennium = Biennium::with(['members' => function ($q) {
    $q->with('photoMedia')->active()->ordered();
}])->current()->first();

if ($biennium && $biennium->members->isNotEmpty()) {
    $params['biennium_label'] = $biennium->label;
    $params['members'] = $biennium->members->map(function ($m) {
        /* ... */
        return [
            'name' => $m->name, 'slug' => $m->slug, 'position' => $m->position,
            'description' => $m->description ?: 'Líder dedicado a fortalecer a SUCESU e promover a inovação no setor de TIC.',
            'photo' => $m->getPhotoUrl(), 'photo_variants' => $variants,
            'linkedin' => $m->linkedin_url,
        ];
    })->toArray();
} else {
    $params['members'] = [];
}
```

> O integrador é a ponte única entre banco e payload. `current()->first()` sem ordenação
> significa que, com dois biênios marcados como atuais (possível sob concorrência), vence
> o que o Postgres devolver primeiro. `biennium_label` só é definido quando há membros —
> por isso a view cai no texto padrão quando a lista está vazia.

```php
// V2/Modules/Institucional/SobreNos/Services/EmpresasImpactadasCalculator.php:55-78
public function calcularImpactoTotal()
{
    $associadosPorEstado = $this->getAssociadosPorEstado();
    $impactoTotal = 0;
    foreach ($associadosPorEstado as $state => $numAssociados) {
        if (!isset($this->fatores[$state])) { continue; }
        $fator = $this->fatores[$state]['fator_combinado'];
        $impactoTotal += $numAssociados * self::MULTIPLICADOR_NACIONAL * $fator;
    }
    return $impactoTotal;
}

public function getValorArredondado()
{
    $impacto = $this->calcularImpactoTotal();
    return ceil($impacto / 10000) * 10000 / 1000;   // valor em milhares
}
```

> O valor final é em **milhares** para casar com o sufixo “mil+” do contador: impacto
> 1.234.567 → `ceil(123,4567) = 124` → `124*10 = 1240` → “1240 mil+”. O multiplicador é `10`, embora o
> próprio JSON de fatores sugira `5` (`Data/empresas-impactadas-por-estado.json:137-148`).

```php
// V2/Modules/Institucional/SobreNos/LseWidgets/Widgets/SobreNosCountersWidget.php:58-75
foreach ($parameters['counters'] ?? [] as $i => $counter) {
    $label = mb_strtolower($counter['label'] ?? '');

    if (strpos($label, 'associado') !== false) {
        $parameters['counters'][$i]['value'] = (string) AssociadoInstituicao::active()->count();
    }
    if (strpos($label, 'ano') !== false || strpos($label, 'história') !== false || strpos($label, 'historia') !== false) {
        $parameters['counters'][$i]['value'] = (string) (date('Y') - self::FOUNDED_YEAR);
    }
    if (strpos($label, 'empresa') !== false && (strpos($label, 'impactad') !== false || strpos($label, 'indiret') !== false)) {
        if ($empresasImpactadas === null) {
            $empresasImpactadas = (new EmpresasImpactadasCalculator())->getValorArredondado();
        }
        $parameters['counters'][$i]['value'] = (string) $empresasImpactadas;
    }
}
```

> Três `if` independentes: um rótulo que contenha “associado” **e** “ano” recebe o valor
> de anos (o último vence). Não há cache do cálculo; o `prepare()` roda duas vezes por
> request no portal, então `AssociadoInstituicao::active()->count()` e o cálculo de
> empresas são executados em dobro.

```php
// V2/Modules/Institucional/SobreNos/Controllers/SobreNosPortalController.php:62-67
$config = LayoutBaseConfig::where('key', 'sobre_nos_sections')->first();
$sections = $config ? $config->payload : [];
$sections = $this->filterDisabledSections($sections);
$sections = $this->integrator->integrate($sections);
$sections = $this->renderer->prepareSections($sections);
```

> A ordem é normativa: filtrar **antes** de integrar evita hidratar dados de uma seção que
> não será exibida; preparar **depois** de integrar garante que os anexos da seção team
> (nenhum) não sobrescrevam os membros injetados.

### 2.5 Algoritmos, consultas e custos

- **Listagem CMS de membros**: `with(['biennium','photoMedia'])` +
  `orderBy('sort_order')` + `paginate(15)`, com filtro opcional `biennium_id`
  (`BoardMemberController.php:38-45`). Duas queries + count; `photoMedia` evita N+1 na
  listagem.
- **Integração da equipe**: 1 query de biênio + 1 de membros + 1 de mídia (eager loading
  `photoMedia`), sempre limitada ao biênio corrente (`SobreNosDataIntegrator.php:42-45`).
- **Contadores de associados**: 1 `count()` por render (2× por request devido ao duplo
  `prepareSections`) (`SobreNosCountersWidget.php:62`).
- **Empresas impactadas**: 1 query de regionais ativas + **1 query por regional**
  (`whereHas('empresa', is_active)`) → N+1 proporcional ao número de unidades regionais
  (`EmpresasImpactadasCalculator.php:32-50`). Nenhum cache; o JSON é lido do disco no
  construtor a cada instância (`:15-26`).
- **`clearPageCache`**: `KEYS <prefix>:page:*` é O(N) no keyspace Redis e bloqueia o
  servidor; chamado em toda mutação (`SobreNosCmsController.php:96-102`;
  `BoardMemberController.php:256-268`).
- **Busca**: cada save de membro faz 1 upsert no índice CMS e 1 no público (ou 1 delete)
  (`Models/BoardMember.php:218-257`).

## 3. Particularidades

### 3.1 O que só existe aqui

- **Seção como unidade de layout dirigida por dados**, com cinco tipos proprietários
  (`sobre-nos-about|team|video|counters|testimonial`) e o prefixo `sobre-nos-` usado pelo
  editor para a categoria “Personalizados”.
- **Hidratação de uma seção a partir de tabela**: `SobreNosDataIntegrator` é o único ponto
  do LSE que injeta registros de domínio dentro de `parameters` antes do `prepare()`.
- **Valores de contador derivados do texto do rótulo**: nenhum outro módulo recalcula
  conteúdo editorial a partir do label.
- **Fatores econômicos em arquivo JSON versionado** (`Data/empresas-impactadas-por-estado.json`)
  com 11 UFs e fórmula documentada no próprio arquivo (`:2-4,119-148`).
- **Biênio como dimensão temporal da diretoria**: `is_current` exclusivo + `end_year`
  derivado; a página sempre mostra o biênio corrente, não o “selecionado”.

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-SOBRENOS-01 | `ensureIds` reescreve `order=índice+1` em todas as seções e descarta entradas não-array | A ordem do array é a ordem de render; itens inválidos somem sem erro | `Services/SobreNosSectionNormalizer.php:22-33` |
| RN-SOBRENOS-02 | Toda seção precisa de `id`; ausente recebe `v2_uuid()` (UUID v4) | Sem id o editor não abre/reordena a seção | `:27-29`; `V2/Core/Helpers/helpers.php:61-69` |
| RN-SOBRENOS-03 | O portal remove o tipo `sobre-nos-testimonial` antes de integrar/renderizar | Editar o depoimento no CMS não muda nada no portal | `SobreNosPortalController.php:33-35,95-104` |
| RN-SOBRENOS-04 | A seção team usa o **primeiro** biênio com `is_current=true` e apenas membros `is_active=true`, ordenados por `sort_order` | Membros de biênios antigos ou inativos nunca aparecem na página | `Services/SobreNosDataIntegrator.php:42-46`; `Models/Biennium.php:83-86`; `Models/BoardMember.php:191-203` |
| RN-SOBRENOS-05 | `biennium_label` só é definido quando há ao menos 1 membro ativo | Lista vazia → texto “Em breve, conheça nossa diretoria.” | `Services/SobreNosDataIntegrator.php:46-66`; `Views/lse/render/team.blade.php:47-49` |
| RN-SOBRENOS-06 | Membro sem `description` recebe a frase “Líder dedicado a fortalecer a SUCESU e promover a inovação no setor de TIC.” na seção team | A frase não é persistida; só existe no payload preparado | `Services/SobreNosDataIntegrator.php:58` |
| RN-SOBRENOS-07 | Contadores com rótulo contendo `associado` exibem `AssociadoInstituicao::active()->count()` | O valor digitado no CMS é ignorado | `SobreNosCountersWidget.php:61-63` |
| RN-SOBRENOS-08 | Contadores com rótulo contendo `ano`/`história`/`historia` exibem `date('Y') - 1965` | Em 2026 o valor é 61, independentemente do CMS | `:65-67,13` |
| RN-SOBRENOS-09 | Contadores com rótulo contendo `empresa` **e** (`impactad` ou `indiret`) exibem o valor arredondado do cálculo por estado | “Empresas Impactadas” vira “N mil+” | `:69-75`; `EmpresasImpactadasCalculator.php:73-78` |
| RN-SOBRENOS-10 | `end_year` é sempre `start_year + 1`; `is_current` é exclusivo | O ano final enviado no formulário é ignorado | `BoardMemberController.php:172,210`; `:164-166,204-206` |
| RN-SOBRENOS-11 | `slug` é gerado por `str_slug(name)` apenas quando vazio, no `creating` e no `saving` | Renomear o membro não muda a URL; não há unicidade | `Models/BoardMember.php:90-99` |
| RN-SOBRENOS-12 | `photo` (caminho legado) é reescrito a cada save a partir de `photo_id`; sem `photo_id`, vira `null` | Membro sem foto no POST perde a foto anterior | `BoardMemberController.php:304-324` |
| RN-SOBRENOS-13 | `photo_id` é `varchar(36)` mas referencia `v2_gallery_media.id` (`increments`, inteiro) | Funciona por coerção de string; não é FK e não há integridade referencial | `Migrations/2026_07_20_000003_add_photo_id_to_v2_board_members_table.php:19`; `V2/Services/Gallery/Migrations/2026_06_28_000000_create_v2_gallery_media_table.php:24` |
| RN-SOBRENOS-14 | O formulário de membro não envia `sort_order` nem `is_active` | Todo update grava `sort_order=0` e `is_active=true` | `Views/cms/members/_form.blade.php:17-70`; `BoardMemberController.php:287-288` |
| RN-SOBRENOS-15 | Soft delete de biênio não altera membros nem apaga a FK | Membros continuam listados com biênio “—” e deixam de aparecer na seção team se aquele era o corrente | `BoardMemberController.php:232-249`; `Models/BoardMember.php:122-125` |
| RN-SOBRENOS-16 | Toda mutação tenta apagar `page:*` no Redis; falha só gera `Log::warning` | Conteúdo pode ficar até 5 min desatualizado se o Redis falhar | `SobreNosCmsController.php:94-106`; `BoardMemberController.php:256-268` |
| RN-SOBRENOS-17 | O portal emite **dois** JSON-LD `AboutPage` (um do `SEOService`, outro do parâmetro `schemas` da view), com descrições diferentes | Dados estruturados duplicados | `SobreNosPortalController.php:77-83`; `Views/portal/index.blade.php:5-12`; `V2/layouts/portal/metadata/seo.blade.php:36-50` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Payload vazio**: salvar o editor sem seções grava `[]` e o portal mostra o alerta de
  “ainda não configurado”; não há fallback para o conteúdo semeado.
- **JSON inválido**: `json_decode` devolve `null` → `$payload=[]` → **apaga** todas as
  seções e ainda responde sucesso (`SobreNosCmsController.php:60-63,84`).
- **Biênio sem membros**: seção team mostra a mensagem padrão; o título manual (“Biênio
  2026-2027”) permanece porque não é derivado.
- **Mais de um biênio corrente**: `storeBiennium` desmarca todos antes de inserir, mas as
  operações não estão em transação; duas requisições concorrentes podem deixar dois
  marcados e o portal escolhe um arbitrário (`BoardMemberController.php:164-174`).
- **Dois membros com o mesmo nome**: mesmo slug, sem unique → `/equipe/x` abre sempre o
  primeiro; o sitemap publica a mesma URL duas vezes.
- **Membro inativo**: continua acessível no CMS e no sitemap (que não filtra `is_active`),
  mas `/equipe/{slug}` responde 404 e ele não aparece na seção team.
- **Mídia apagada da Galeria**: `getPhotoUrl()` devolve `photo` (caminho antigo) mesmo que
  a mídia não exista mais; a imagem quebra no `picture` (sem checagem de existência).
- **`photo_id` de outro módulo**: `GalleryMedia::find` não valida `owner_module`; qualquer
  mídia da galeria pode ser anexada ao membro.
- **Rótulo que casa duas heurísticas**: aplica-se a última verificada (empresas > anos >
  associados, na ordem do código); o valor anterior é sobrescrito.
- **Unidade regional sem `state`**: ignorada no cálculo; UF sem entrada no JSON também é
  ignorada (`EmpresasImpactadasCalculator.php:35-38,61-63`).
- **Nenhum associado ativo**: `getValorArredondado()` devolve `0` → contador “0 mil+”
  (não há piso de 10.000 apesar do que afirma `wiki/v2/calculo-empresas-impactadas.md:74`).
- **Salvar sem permissão de escrita**: `read` bloqueia POST/PUT/DELETE com redirect back e
  `error` — mas a tela foi renderizada com os botões ocultos, então só ocorre por POST
  forjado.
- **Cache de página**: `/equipe/{slug}` também é cacheado por 5 min; desativar o membro
  limpa o cache, mas o 404 só aparece após a limpeza (que é best-effort).

### 3.4 Armadilhas de reimplementação

- **Não** trate a seção team como conteúdo editorial: qualquer `members` no JSON é
  sobrescrito pelo integrador (ou esvaziado). O CMS de seções só controla subtítulo,
  título e descrição.
- **Não** reaproveite o normalizador esperando preservar `order` enviado: ele renumera
  tudo; “mover para a posição 3” significa reordenar o array antes de salvar.
- **Não** use `id` de seção como chave estável de negócio: ele é regenerado sempre que o
  payload chega sem id (seeder legado, migração) e não é referenciado por nenhuma tabela.
- **Não** assuma que o valor do contador vem do CMS: o `prepare()` o substitui; a única
  forma de mudar o número é mudar o dado de origem.
- **Não** conte com `photo_id` como UUID: é o id inteiro da Galeria gravado como string;
  validar como UUID quebra o formulário (`max:36` só limita tamanho).
- **Não** derive o slug de `name` em updates: o hook só gera quando o campo está vazio;
  reimplementar com “sempre regenerar” quebra URLs publicadas e o sitemap.
- **Não** renderize o payload direto: sem `prepareSections` as imagens ficam como IDs e
  os contadores como valores salvos.
- **Não** chame `prepareSections` duas vezes por engano em uma reimplementação “mais
  limpa” — mas, se mudar para uma vez, os números continuam iguais (as funções são
  idempotentes); o que muda é o custo (metade das queries).

### 3.5 Divergências documentação × código e dívidas

- `wiki/v2/calculo-empresas-impactadas.md:73-75` afirma arredondamento “mínimo 10.000” e
  multiplicador `10` “dobrado do original”; o código não tem piso e o JSON sugere `5`
  (`Data/empresas-impactadas-por-estado.json:139`). **Código vence**: sem piso, fator 10.
- A mesma wiki diz que o valor é “atualizado automaticamente conforme novos associados”;
  não há invalidação de cache do contador, apenas o cache de página de 5 min.
- O sitemap publica `/diretoria` e `/diretoria/{slug}`, rotas que só respondem 301; e não
  publica `/sobre-nos` nem `/equipe/{slug}` (`SeoPortalController.php:295-323`).
- `$disabledSectionTypes` desativa o depoimento no portal sem qualquer aviso no CMS; o
  seeder continua criando a seção (`Seeds/SobreNosDefaultSectionsSeeder.php:105-117`).
- `BoardMember::syncToSearch` tem um `try/catch` interno e o hook `saved` outro: falhas de
  índice nunca interrompem o save, mas também não são reprocessadas.
- `Biennium::$fillable` inclui `id`, mas o controller atribui `id` diretamente e o hook
  `creating` também preenche — duplicação inofensiva.
- O editor CMS exibe `history` de `LayoutBaseConfig`, mas o `activity-footer` só é
  renderizado quando há histórico; a tela de criação de membro passa `model=null`
  (`Views/cms/editor.blade.php:31-34`; `Views/cms/members/create.blade.php:23`).

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `ModuleProvider` | classe | `__construct()`, `getConfig(): array` | Instanciado uma vez no boot | `V2ServiceProvider`, `SidebarComposer`, `GestaoPermissoes` |
| `SobreNosSectionNormalizer` | serviço estático | `ensureIds(array $sections): array` | — | `SobreNosCmsController`, migração de normalização |
| `SobreNosDataIntegrator` | serviço | `integrate(array $sections): array` | Seções já filtradas | `SobreNosPortalController` |
| `EmpresasImpactadasCalculator` | serviço | `getAssociadosPorEstado(): array`, `calcularImpactoTotal(): float`, `getValorArredondado(): float`, `getDetalhamento(): array` | JSON de fatores presente; tabelas de conexões disponíveis | `SobreNosCountersWidget` |
| `Biennium` | model | `scopeCurrent`, `members(): HasMany` | Tabela `v2_bienniums` | `SobreNosDataIntegrator`, `BoardMemberController` |
| `BoardMember` | model | `biennium()`, `photoMedia()`, `getPhotoUrl()`, `getPhotoFullUrl()`, `getHtmlBio()`, `scopeActive`, `scopeOrdered`, `scopeBySlug`, `syncToSearch()` | Tabela `v2_board_members` | `SobreNosPortalController`, `BoardMemberController`, busca, Dashboard |
| `SobreNosAbout/Team/Video/Counters/TestimonialWidget` | widgets LSE | `getAttachmentIds(array): array`, `prepare(array, ?Collection): array` | Registrados no `WidgetRegistry` | `LandingSectionsEditorRendererService` |
| `v2-sobrenos::lse.render.*` | views | `$data` = `parameters` preparados, `$section` | Widget correspondente registrado | `landing-sections-viewer` |
| Rotas | HTTP | 11 CMS + 4 públicas | Ver §5 | Portal, CMS, SEO/sitemap |

### 4.2 Contrato de UI/JS

- **Editor de seções**: o componente `landing-sections-editor` recebe `name`, `value`
  (array ou JSON), `label`; serializa em `input[type=hidden]` com id
  `landing-sections-hidden-<name>`; usa os endpoints `v2.landing-sections.{form,validate,preview.save,preview.render,render-section,media.*}`
  (`V2/Services/LandingSectionsEditor/Views/components/forms/landing-sections-editor.blade.php:1-14,19-24,114-130`).
  O formulário do módulo é o `<form id="sobrenos-sections-form">` com `csrf_field()` e o
  botão de salvar fica **fora** do form, ligado por `form="sobrenos-sections-form"`
  (`Views/cms/editor.blade.php:9-27`).
- **Widgets personalizados**: aparecem no dropdown sob “Personalizados” porque o tipo
  começa com `sobre-nos-`; o form de cada um é carregado por AJAX em
  `GET /cms/v2/landing-sections/form?type=sobre-nos-<x>` e recebe `$data` com os
  `parameters` atuais (`LandingSectionsEditorController.php:67-94`).
- **Contadores (DOM/JS)**: `#counters-items-container`, `#btn-add-counter`,
  `#counters-item-template`, `#counters-no-items-msg`; o JS genérico
  (`_item-list-js.blade.php`) clona o `<template>` substituindo `__INDEX__`, reindexa
  `name="parameters[counters][N][campo]"`, ids `sobrenos-counter-N-*` e
  `data-input-id` do image-picker (`Views/lse/forms/counters.blade.php:13-62`;
  `Views/lse/forms/_item-list-js.blade.php:32-90`).
- **Modal de biênios**: `#bienniumModal`, form `#bienniumForm` com hidden
  `biennium_id`/`_method`; funções globais `editBiennium(id,label,startYear,isCurrent)`
  e `resetBienniumForm()`; o action de update é montado por `url("cms/institucional/sobre-nos/bienniums/update") + '/' + id`
  (`Views/cms/members/index.blade.php:111-243`).

### 4.3 Ordem e ciclo de vida

1. `V2ServiceProvider::boot()` instancia `ModuleProvider` → widgets registrados.
2. `loadViewsFrom(V2/Modules/Institucional/SobreNos/Views, 'v2-sobrenos')`.
3. `loadMigrationsFrom(.../Migrations)`.
4. `mapModuleRoutes` registra `cms.php` (com `module.active:SobreNos`) e `web.php`
   (com `page.cache`/`public.cache`).
5. `SidebarComposer` consome `v2.modules.configs['SobreNos']` e filtra por
   `ModuleFlagService`; `GestaoPermissoes` usa a mesma chave para montar a matriz RBAC.
6. Em request do portal, o composer do `SEOServiceProvider` roda antes do render de
   metadata e registra a visita.

### 4.4 Receitas de uso

```php
// Criar uma seção nova com id/ordem válidos antes de persistir no payload
$payload = SobreNosSectionNormalizer::ensureIds([
    ['type' => 'sobre-nos-about', 'parameters' => ['title' => 'Sobre a SUCESU']],
    ['type' => 'sobre-nos-team',  'parameters' => ['title' => 'Diretoria']],
]);
// => [['id'=>uuid,'order'=>1,'type'=>'sobre-nos-about',...], ['id'=>uuid,'order'=>2,...]]
LayoutBaseConfig::firstOrNew(['key' => 'sobre_nos_sections'])->fill(['payload' => $payload])->save();
```

```php
// Reutilizar o cálculo de impacto fora do widget
$calc = app(\V2\Modules\Institucional\SobreNos\Services\EmpresasImpactadasCalculator::class);
$calc->getAssociadosPorEstado(); // ['SP' => 12, 'MG' => 3]
$calc->getValorArredondado();    // 100.0  (=> "100" + sufixo "mil+")
$calc->getDetalhamento();        // ['SP' => ['estado'=>'São Paulo','associados'=>12,...]]
```

```php
// Buscar o membro público por slug (só ativos)
$member = \V2\Modules\Institucional\SobreNos\Models\BoardMember::with('photoMedia','biennium')
    ->active()->bySlug('maria-silva')->firstOrFail(); // 404 se inativo/inexistente
echo $member->getPhotoFullUrl();   // URL resolvida pelo StorageService
echo $member->getHtmlBio();        // HTML do AST (null se vazio ou falha)
```

```blade
{{-- Renderizar a seção team em outro contexto com dados já integrados --}}
@include('v2-sobrenos::lse.render.team', [
    'data' => app(\V2\Modules\Institucional\SobreNos\Services\SobreNosDataIntegrator::class)
        ->integrate([['type' => 'sobre-nos-team', 'parameters' => ['title' => 'Diretoria']]])[0]['parameters'],
])
```

### 4.5 Anti-uso

- **Colocar membros no JSON da seção team** — o integrador substitui `parameters.members`
  pelo resultado do banco (ou por `[]`); qualquer nome escrito à mão nunca aparece
  (`SobreNosDataIntegrator.php:25-27,46-66`).
- **Chamar `SobreNosSectionNormalizer::ensureIds` esperando manter `order` do cliente** —
  a ordem é sempre `índice+1`; reordenações precisam acontecer no array
  (`SobreNosSectionNormalizer.php:31`).
- **Editar `v2_board_members.sort_order`/`is_active` pelo formulário** — os campos não
  existem no form e o `update` grava `0`/`true`; use SQL/gestão dedicada ou adicione os
  campos ao `_form` (`BoardMemberController.php:287-288`).

### 4.6 Custos e limites da integração

- Cache de página: 5 min, chave `page:sha1(fullUrl)`, invalidado (best-effort) por
  `KEYS`/`DEL` em toda mutação.
- Sem paginação no payload: o editor serializa o JSON inteiro no `input hidden`; payloads
  grandes aumentam o POST (limite do `post_max_size` do PHP, não configurado no módulo).
- Contadores executam queries em cada render e não têm cache próprio; com N regionais, o
  cálculo faz N+1 consultas.
- Upload de foto passa pelo `media-input` (`ownerModule='sobrenos'`, `accept='image/*'`),
  sem limite próprio no módulo; o limite é o da Galeria (`max` do endpoint de upload).

## 5. Rotas e contratos HTTP

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|---|
| grupo | — | `cms/institucional/sobre-nos` | — | web; module.active:SobreNos; CmsAuthenticate→V2Permission | — | `SobreNos:read/write` | Agrupa os 11 endpoints do CMS | `Routes/cms.php:3-6`; `V2ServiceProvider.php:290-307` |
| endpoint | GET | `/cms/institucional/sobre-nos/` | `v2.sobrenos.cms.sections` | idem | `SobreNosCmsController@sections` | read | HTML do editor LSE + histórico | `Routes/cms.php:8-9`; `SobreNosCmsController.php:31-46` |
| endpoint | POST | `/cms/institucional/sobre-nos/salvar` | `v2.sobrenos.cms.sections.save` | idem | `saveSections` | write | Grava payload, limpa cache, audita, 302 | `Routes/cms.php:10-11`; `:54-87` |
| endpoint | GET | `/cms/institucional/sobre-nos/members` | `v2.sobrenos.cms.members` | idem | `BoardMemberController@index` | read | Lista 15/página + biênios + modal | `Routes/cms.php:14-15`; `BoardMemberController.php:36-48` |
| endpoint | GET | `/cms/institucional/sobre-nos/members/novo` | `v2.sobrenos.cms.members.create` | idem | `create` | read | Formulário de criação | `Routes/cms.php:16-17`; `:55-60` |
| endpoint | POST | `/cms/institucional/sobre-nos/members/novo` | `v2.sobrenos.cms.members.store` | idem | `store` | write | Cria membro, audita, 302 | `Routes/cms.php:18-19`; `:68-88` |
| endpoint | GET | `/cms/institucional/sobre-nos/members/editar/{id}` | `v2.sobrenos.cms.members.edit` | idem | `edit` | read | Formulário preenchido | `Routes/cms.php:20-21`; `:96-103` |
| endpoint | PUT/POST | `/cms/institucional/sobre-nos/members/editar/{id}` | `v2.sobrenos.cms.members.update` | idem | `update` | write | Atualiza membro, audita, 302 | `Routes/cms.php:22-23`; `:112-128` |
| endpoint | DELETE | `/cms/institucional/sobre-nos/members/{id}` | `v2.sobrenos.cms.members.destroy` | idem | `destroy` | write | Soft delete + remove índices, 302 | `Routes/cms.php:24-25`; `:136-154` |
| endpoint | POST | `/cms/institucional/sobre-nos/bienniums/store` | `v2.sobrenos.cms.bienniums.store` | idem | `storeBiennium` | write | Cria biênio, 302 | `Routes/cms.php:28-29`; `:162-190` |
| endpoint | PUT/POST | `/cms/institucional/sobre-nos/bienniums/update/{id}` | `v2.sobrenos.cms.bienniums.update` | idem | `updateBiennium` | write | Atualiza biênio, 302 | `Routes/cms.php:30-31`; `:199-224` |
| endpoint | DELETE | `/cms/institucional/sobre-nos/bienniums/{id}` | `v2.sobrenos.cms.bienniums.destroy` | idem | `destroyBiennium` | write | Soft delete de biênio, 302 | `Routes/cms.php:32-33`; `:232-249` |
| endpoint | GET | `/sobre-nos` | `v2.sobrenos.portal.index` | web; maintenance.v2; page.cache; public.cache | `SobreNosPortalController@index` | pública | HTML das seções | `Routes/web.php:6-7`; `:60-86` |
| endpoint | GET | `/equipe/{slug}` | `v2.sobrenos.portal.member.show` | idem | `show` | pública | HTML do membro ou 404 | `Routes/web.php:10-11`; `:112-148` |
| redirect | GET | `/diretoria` | `v2.diretoria.portal.index.redirect` | idem | `redirectOldDiretoria` | pública | 301 `/sobre-nos#diretoria` | `Routes/web.php:14-15`; `:155-158` |
| redirect | GET | `/diretoria/{slug}` | `v2.diretoria.portal.bio.redirect` | idem | `redirectMember` | pública | 301 `/equipe/{slug}` | `Routes/web.php:17-18`; `:166-169` |

**CMS**: todos redirecionam com flash; não há API JSON no módulo. **Portal**: `GET`
apenas, sem query params; paginação/filtros não se aplicam. **SEO**: title/description
definidos no controller; canonical = URL atual.

## 6. Dados e modelo de domínio

Visão completa em [`dados.md`](dados.md). Resumo:

```mermaid
erDiagram
  v2_bienniums ||--o{ v2_board_members : "biennium_id (cascade)"
  v2_gallery_media ||--o{ v2_board_members : "photo_id (sem FK)"
  v2_layout_base_configs {
    uuid id PK
    varchar key
    jsonb payload
  }
```

- `v2_bienniums` (PK `id` uuid; `label`, `start_year`, `end_year`, `is_current`,
  `metadata` jsonb; soft delete; índices `is_current` e `(start_year,end_year)`).
- `v2_board_members` (PK `id` uuid; FK `biennium_id` cascade; `name`, `slug`, `position`,
  `description`, `bio` jsonb, `photo`, `photo_id`, `linkedin_url`, `email`, `sort_order`,
  `is_active`, `metadata`, `created_by`; soft delete; índices por biênio, ativo, ordem e
  slug).
- `v2_layout_base_configs` (do SLE): uma linha `key='sobre_nos_sections'` com o array de
  seções em `payload`.

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Membros: criar (is_active=true por padrão)
  Membros --> Inativo: "is_active=false (via SQL)"
  Inativo --> Membros: "is_active=true"
  Membros --> Arquivado: DELETE (soft)
  Arquivado --> [*]
  [*] --> Biennium
  Biennium --> Corrente: is_current=true (desmarca os demais)
  Corrente --> Biennium: is_current=false
  Biennium --> ArquivadoB: DELETE (soft, membros intactos)
```

```mermaid
sequenceDiagram
  participant U as Operador
  participant C as SobreNosCmsController
  participant N as SectionNormalizer
  participant DB as v2_layout_base_configs
  participant R as Redis
  participant A as ActivityLogger
  U->>C: POST salvar (landing_sections_json)
  C->>C: validate required|string
  C->>N: ensureIds(json_decode)
  N-->>C: seções com id/order
  C->>DB: firstOrNew(key=sobre_nos_sections)->save()
  C->>R: KEYS/DEL <prefix>:page:*
  C->>A: log(updated, old, new)
  C-->>U: 302 + flash
```

- `FL-SOBRENOS-01` — salvar seções: validação → normalização → upsert → cache → auditoria
  (`SobreNosCmsController.php:54-87`).
- `FL-SOBRENOS-02` — render `/sobre-nos`: leitura → filtro → integração → preparo ×2 →
  render (`SobreNosPortalController.php:60-86`; `landing-sections-viewer.blade.php:11-48`).
- `FL-SOBRENOS-03` — CRUD de membro: FormRequest → `fillMember` (foto/slug/save) → hooks
  `saved`/`deleted` (busca) → cache → auditoria (`BoardMemberController.php:68-154`;
  `Models/BoardMember.php:82-117`).
- `FL-SOBRENOS-04` — biênio corrente: desmarcar → gravar → cache → auditoria
  (`BoardMemberController.php:162-224`).

## 8. UI CMS

Três telas: editor de seções, listagem de membros (com modal de biênios) e formulário de
membro (create/edit compartilham `_form`). Detalhes de campos, mensagens e acessibilidade
em [`cms.md`](cms.md). Marcadores `UI-SOBRENOS-01..04`.

## 9. UI Portal

`/sobre-nos` é um layout “full width” sem header/footer do portal (`showHeader=false`,
`showMainFooter=false` etc.), renderizando as seções; `/equipe/{slug}` é uma página de
perfil com foto sticky. SEO, mídia e cache em [`portal.md`](portal.md).

## 10. Configuração, permissões e flags

**`ModuleProvider::getConfig()`** (`ModuleProvider.php:30-55`):

| Chave | Valor |
|---|---|
| `title` | `Sobre Nós` |
| `description` | `Gerenciamento da página Sobre Nós e Diretoria Executiva.` |
| `icon` | `bi bi-building` |
| `position` | `4` |
| `active_pattern` | `['cms/institucional/sobre-nos*']` |
| `route` | `v2.sobrenos.cms.sections` |
| `subModulesLevel1[0]` | `{title:'Seções', icon:'bi bi-layers', route:'v2.sobrenos.cms.sections', position:1}` |
| `subModulesLevel1[1]` | `{title:'Membros', icon:'bi bi-people', route:'v2.sobrenos.cms.members', position:2}` |
| `group` | injetado no boot como `Institucional` (`V2ServiceProvider.php:107-110`) |

- **Permissão**: chave de módulo `SobreNos` (nome do diretório) no JSON
  `v2_roles.permissions_v2`; valores `read`/`write`/ausente. `super-admin` e o e-mail
  `config('v2.support.email')` passam sempre; o grupo `SUPPORT` é restrito, mas este
  módulo é `Institucional` (`V2/Core/Services/PermissionService.php:46-69`;
  `V2/Core/Middleware/V2PermissionMiddleware.php:50-77`).
- **Flag**: `module.active:SobreNos` em `v2_module_flags`; ausência de linha = ativo
  (`V2/Core/Services/ModuleFlagService.php:82-93`). Desativado, o CMS responde `404
  Modulo desativado.` (AJAX: 404 JSON) e o menu some, mas `/sobre-nos` continua
  público (a flag só é aplicada às rotas CMS).
- **Config de app usada**: `cache.prefix` (default `laravel`) para o padrão de limpeza
  (`SobreNosCmsController.php:98`).

## 11. Observabilidade, erros e limites

- `activity_log` com descrições `created`/`updated`/`deleted` e propriedades
  `{type, old, new}` ou `{type, name|label}`; o serviço acrescenta `v2_audit`,
  `ip_address`, `user_agent` e `causer` (`V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`).
- Logs de aplicação: `[SobreNosCmsController] Falha ao limpar cache de páginas: ...` e
  `[BoardMemberController] Falha ao limpar cache de páginas: ...` (warning);
  `[SobreNosDataIntegrator] Erro ao buscar membros da diretoria: ...` (error);
  `[BoardMember] Falha ao resolver foto da mídia: ...` (error);
  `[BoardMember] Falha no syncToSearch: ...` (error);
  `[SobreNosPortal] Falha ao renderizar bio: ...` (warning).
- Fail-open: falha de Redis não bloqueia a escrita; falha de busca não bloqueia o save;
  falha do integrador devolve seção sem membros; falha de renderização da bio devolve
  HTML vazio.
- Códigos: `200` portal/editor, `301` redirects, `302` pós-mutação, `401` AJAX sem sessão,
  `403` AJAX sem permissão, `404` id/slug inexistente ou módulo desativado, `422` no
  endpoint de validação do LSE.
- Limites: paginação fixa 15; upload só limitado pela Galeria; `photo_id` 36 chars;
  `name`/`position`/`label` 255; `start_year` 1900–2100.

## 12. Seeds e dados iniciais

- `SobreNosDefaultSectionsSeeder` é chamado por `DatabaseSeeder::callSeeds()` logo após
  `SucesuHomepageSeeder` (`database/seeds/DatabaseSeeder.php:92-93`).
- Envia 14 imagens de `REFERENCE/assets/...` para a Galeria (`module='sle'`) e monta 5
  seções fixas com `id=v2_uuid()` e `order=1..5`; grava/atualiza a linha
  `sobre_nos_sections` (`Seeds/SobreNosDefaultSectionsSeeder.php:26-39,41-131`).
- **Não é idempotente do ponto de vista de conteúdo**: sobrescreve o payload a cada
  execução, descartando edições feitas no CMS (`:126-131`); e cada imagem processada é
  reenviada (sem deduplicação em `GalleryService::seedFromPath`, `:110-127`).
- Textos semeados relevantes: título “Sobre a SUCESU”, biênio “Biênio 2026-2027”,
  contadores `45+ Anos de História`, `5000+ Associados`, `100 mil+ Empresas Impactadas`,
  depoimento “John Smith” (não renderizado).
- A migração `2026_09_01_000001_normalize_sobre_nos_section_ids` corrige payloads antigos
  sem `id`/`order` (ver [`dados.md`](dados.md)).

## 13. Lacunas

- `LG-SOBRENOS-01` — Não há dados reais de produção para estimar quantos membros/biênios
  existem; impacto do N+1 do cálculo de empresas em produção é desconhecido.
- `LG-SOBRENOS-02` — Não foi possível confirmar se algum processo operacional usa o filtro
  `?biennium_id=` (sem UI) ou chama `getDetalhamento()`; nenhum consumidor encontrado no
  código. Procurar em scripts/relatórios externos.
- `LG-SOBRENOS-03` — O conteúdo de `REFERENCE/` (usado pelo seeder) não faz parte do
  corpus; a paridade visual exata das imagens semeadas não é verificável aqui.
- `LG-SOBRENOS-04` — Não há teste cobrindo `EmpresasImpactadasCalculator` nem
  `SobreNosDataIntegrator`; o comportamento em base vazia foi inferido do código.
- `LG-SOBRENOS-05` — `Sentinel::getUser()->id` para `created_by`: o tipo real do id de
  usuário (int/uuid) depende da migration de `users`; a coluna é `varchar(36)`.
- `LG-SOBRENOS-06` — A ordem de exibição de membros com `sort_order` empatado depende do
  plano do Postgres; não há critério secundário.

## 14. Checklist de paridade

- [ ] `PC-SOBRENOS-01` — `/sobre-nos` renderiza as seções na ordem do array do payload; `order` do JSON é reescrito para `1..n` ao salvar (`SobreNosSectionNormalizer.php:22-33`).
- [ ] `PC-SOBRENOS-02` — Seção sem `id` recebe UUID v4 e passa a ser editável/reordenável (`:27-29`).
- [ ] `PC-SOBRENOS-03` — Seção `sobre-nos-testimonial` presente no payload não gera HTML no portal (`SobreNosPortalController.php:95-104`).
- [ ] `PC-SOBRENOS-04` — Seção team mostra exatamente os membros ativos do biênio corrente, ordenados por `sort_order` (`SobreNosDataIntegrator.php:42-63`).
- [ ] `PC-SOBRENOS-05` — Sem membros ativos no biênio corrente, a seção mostra “Em breve, conheça nossa diretoria.” (`Views/lse/render/team.blade.php:47-49`).
- [ ] `PC-SOBRENOS-06` — Contador com rótulo “Associados” exibe a contagem de `v2_associado_instituicoes.is_active=true` (`SobreNosCountersWidget.php:61-63`).
- [ ] `PC-SOBRENOS-07` — Contador “Anos de História” exibe `ano_atual - 1965` (`:65-67`).
- [ ] `PC-SOBRENOS-08` — Contador “Empresas Impactadas” exibe `ceil(Σ assoc_UF×10×fator/10000)*10` (milhares), com sufixo “mil+” (`EmpresasImpactadasCalculator.php:55-78`).
- [ ] `PC-SOBRENOS-09` — Criar biênio com `is_current=1` desmarca o anterior e grava `end_year=start_year+1` (`BoardMemberController.php:164-174`).
- [ ] `PC-SOBRENOS-10` — Criar membro sem slug gera slug a partir do nome, e editar o nome **não** altera o slug (`Models/BoardMember.php:90-99`).
- [ ] `PC-SOBRENOS-11` — Foto do membro é resolvida de `photo_id` para `GalleryMedia.file_path`; sem `photo_id`, `photo=null` (`BoardMemberController.php:304-324`).
- [ ] `PC-SOBRENOS-12` — `/equipe/{slug}` de membro inativo ou inexistente responde 404 (`SobreNosPortalController.php:115-118`).
- [ ] `PC-SOBRENOS-13` — `/diretoria` responde 301 para `/sobre-nos#diretoria` e `/diretoria/{slug}` para `/equipe/{slug}` (`:155-169`).
- [ ] `PC-SOBRENOS-14` — Toda mutação tenta apagar `page:*` e não falha se o Redis estiver fora (`SobreNosCmsController.php:94-106`).
- [ ] `PC-SOBRENOS-15` — Salvar membro reindexa `diretoria` (CMS) e `board_member` (público, só se ativo); excluir remove ambos (`Models/BoardMember.php:101-116,218-257`).
- [ ] `PC-SOBRENOS-16` — Usuário `read` vê as telas sem botões de escrita e recebe “Operação negada... apenas permissão de leitura” em POST (`V2PermissionMiddleware.php:101-123`).
- [ ] `PC-SOBRENOS-17` — `POST /salvar` sem `landing_sections_json` mantém o payload anterior e devolve erro de validação (`SobreNosCmsController.php:56-58`).
- [ ] `PC-SOBRENOS-18` — Payload inválido (não-JSON) grava `[]` e o portal exibe o alerta de não configurado (`:60-63`; `Views/portal/index.blade.php:29-36`).
- [ ] `PC-SOBRENOS-19` — Ranking de busca pública resolve `diretoria` para `/equipe/{slug}` (`V2/Services/Search/Services/SearchService.php:80-82`).
- [ ] `PC-SOBRENOS-20` — Menu CMS mostra “Sobre Nós” na posição 4 com as abas Seções e Membros (`ModuleProvider.php:30-55`).

## 15. Evidências consultadas

| Arquivo | Faixas |
|---|---|
| `V2/Modules/Institucional/SobreNos/ModuleProvider.php` | 18-55 |
| `V2/Modules/Institucional/SobreNos/Routes/cms.php` | 3-34 |
| `V2/Modules/Institucional/SobreNos/Routes/web.php` | 3-19 |
| `V2/Modules/Institucional/SobreNos/Controllers/SobreNosCmsController.php` | 31-106 |
| `V2/Modules/Institucional/SobreNos/Controllers/SobreNosPortalController.php` | 33-169 |
| `V2/Modules/Institucional/SobreNos/Controllers/BoardMemberController.php` | 36-324 |
| `V2/Modules/Institucional/SobreNos/Services/SobreNosSectionNormalizer.php` | 18-36 |
| `V2/Modules/Institucional/SobreNos/Services/SobreNosDataIntegrator.php` | 20-73 |
| `V2/Modules/Institucional/SobreNos/Services/EmpresasImpactadasCalculator.php` | 11-102 |
| `V2/Modules/Institucional/SobreNos/LseWidgets/Widgets/SobreNosCountersWidget.php` | 13-79 |
| `V2/Modules/Institucional/SobreNos/Models/BoardMember.php` | 38-257 |
| `V2/Modules/Institucional/SobreNos/Models/Biennium.php` | 19-87 |
| `V2/Modules/Institucional/SobreNos/Migrations/*.php` | 1-83 (cada) |
| `V2/Modules/Institucional/SobreNos/Seeds/SobreNosDefaultSectionsSeeder.php` | 20-140 |
| `V2/Modules/Institucional/SobreNos/Views/**` | conforme citado |
| `V2/Core/Providers/V2ServiceProvider.php` | 30-36, 88-135, 290-330 |
| `V2/Core/Middleware/{CmsAuthenticate,V2PermissionMiddleware,PageCacheMiddleware,ModuleAccessMiddleware}.php` | conforme citado |
| `V2/Services/LandingSectionsEditor/Services/{WidgetRegistry,LandingSectionsEditorRendererService}.php` | conforme citado |
| `V2/Services/SEO/Controllers/SeoPortalController.php` | 295-323 |
| `database/seeds/DatabaseSeeder.php` | 88-100 |
