From c0ffc220261a957fe0716f3ac83a2272dfbf6bf4 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 15:22:12 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(C118):=20p=C3=A1gina=20de=20detalhe=20?= =?UTF-8?q?da=20pessoa=20=E2=80=94=20se=C3=A7=C3=B5es=20montadas=20pelas?= =?UTF-8?q?=20capacidades=20(Closes=20#657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 7 + .../pessoas-detalhe-por-capacidades-impl.md | 83 ++++ .../campanha/(app)/pessoas/[id]/page.tsx | 58 +++ .../campanha/(app)/pessoas/page.tsx | 4 +- .../campaign/people/DeletePersonButton.tsx | 18 +- .../campaign/people/PersonDetailSections.tsx | 368 ++++++++++++++++++ src/utilities/people/peopleData.ts | 180 +++++---- src/utilities/people/personDetail.ts | 170 ++++++++ tests/e2e/campaignPeople.e2e.spec.ts | 141 +++++++ tests/int/personDetail.int.spec.ts | 218 +++++++++++ 10 files changed, 1171 insertions(+), 76 deletions(-) create mode 100644 docs/plans/pessoas-detalhe-por-capacidades-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx create mode 100644 src/components/campaign/people/PersonDetailSections.tsx create mode 100644 src/utilities/people/personDetail.ts create mode 100644 tests/int/personDetail.int.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 8679bb92..35108210 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,9 +4,16 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +<<<<<<< HEAD **Recently resolved (2026-08-11):** **C124** (Issue #668) — tarefa de atividade fica só com **texto, responsável e concluída**: o campo `due` (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array `tasks` da `activity` (o guardrail de liderança perde a comparação de `due` no mesmo item — continua fail-closed sobre título/responsável/contagem), `taskSchema` zod, `parseTasksFormData`/`ParsedActivityTask` (formData), `ActivityFormTaskViewModel`/`ActivityTaskViewModel`, input `datetime-local` do `ActivityTaskFields` (grid `2fr_1fr`) e linha "Prazo: …" do `ActivityTaskChecklist` (subtítulo condicional + `break-words` no título — P2 dos reviewers). `doneAt` ("concluída em") permanece como derivado invisível; `taskTotal`/`taskDoneCount` intocados. **Migration** `20260811_183016_drop_activity_task_due` (gerada; diff do gerador tinha no-op de `google_calendar_sync` por snapshot stale — limpa à mão para só o `DROP COLUMN "due"` de `activity_tasks`; dados descartados conforme decidido no gate). Grep pós: zero `due` fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção [`atividades-tarefas-sem-prazo.md`](plans/atividades-tarefas-sem-prazo.md); impl [`atividades-tarefas-sem-prazo-impl.md`](plans/atividades-tarefas-sem-prazo-impl.md). +======= +**Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md). +>>>>>>> 778ebb56 (feat(C118): página de detalhe da pessoa — seções montadas pelas capacidades (Closes #657)) **Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md). +======= +**Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md). + **Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md). **Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-impl.md). diff --git a/docs/plans/pessoas-detalhe-por-capacidades-impl.md b/docs/plans/pessoas-detalhe-por-capacidades-impl.md new file mode 100644 index 00000000..66025cf2 --- /dev/null +++ b/docs/plans/pessoas-detalhe-por-capacidades-impl.md @@ -0,0 +1,83 @@ +# Impl: Pessoas — página de detalhe da pessoa (seções montadas pelas capacidades) + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #657 +Intenção: docs/plans/pessoas-detalhe-por-capacidades.md +Appetite restante: ~2 dias eng (herdado; sem corte necessário — sem migration, sem escrita) + +## Leitura da intenção + +- **Outcome:** `/campanha/pessoas/` acessível a staff — uma ficha única por pessoa (qualquer capacidade), montada pelas capacidades dela: Ficha, Liderança, Dobradinha, Assessora, Assessorado, Apoiador, Ações. Seção ausente = não renderiza. O detalhe de liderança existente não regride (convivência v1: seção de liderança linka para ele). +- **O que NÃO negociar:** gate staff (leader → `/campanha/contatos`); escopo do assessor espelhado do merge da lista (nunca alargado); sem migration; `Contact` continua a fonte da ficha; sem KPI/votos na página (assimetria votos × estimativas intacta — não há votos aqui, o detalhe de liderança os tem e fica linkado); seções só quando a capacidade existe. +- **O que reavaliar:** a hipótese apontava "loader por `contactID` reusando o merge de `peopleData.ts` (ou um irmão `loadPersonDetail`)". Confirmada — o merge (`mergePeopleSources` + `scopePeopleRows`) é o mesmo contrato do escopo da lista e **deve** ser reusado para o escopo não divergir. O ponto novo: a seção Apoiador exige leitura de `supporter` por `contact`, que o view model da lista não carrega — é dado novo do loader, com o access da própria collection. + +## Abordagem recomendada + +```mermaid +flowchart LR + A["pessoas/[id]/page.tsx
(gate staff + metadata)"] --> B["loadPersonDetail
utilities/people/personDetail.ts"] + B --> C["mergePeopleSources + scopePeopleRows
(peopleData.ts — mesmo contrato da lista)"] + B --> D["supporters por contact
(overrideAccess:false, user — access da collection)"] + B --> E["nomes municípios via portfolio index (cached)"] + B -->|null| F["notFound()"] + A --> G["components/campaign/people/PersonDetailSections.tsx
ficha + seções por capacidade + ações (reuso dos botões da lista)"] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — rota nova `pessoas/[id]` com loader irmão no mesmo subdomínio e seções RSC declarativas por capacidade — porque mantém o escopo do assessor literalmente idêntico ao da lista (mesmo merge, mesmas fontes, mesmos selects) e a página fica fina (dado na rota, forma nas seções). +**Rejeitadas:** B — estender `peopleData.ts` (474 linhas; o loader de detalhe adiciona supporters + nomes de assessorado com granularidade por pessoa — responsabilidade nova, módulo separado no mesmo `people/` é o padrão da casa); C — reusar `loadPeopleListPageData` e achar a linha (carrega a tabela inteira sem paginação só para pegar um id — trabalho ilimitado para uma ficha). + +### Decisões de engenharia + +- **D1 — Contrato do parâmetro de rota.** `[id]` **é `contactID`** (a chave de linha da lista C100, o que a C116 linkará). Validado por `/^[1-9]\d*$/`. _Rejeitadas:_ `leadershipID` (só cobre lideranças — quebra "qualquer pessoa"); slug (Contact não tem slug; criá-lo é migration + colisão). +- **D2 — Loader.** `loadPersonDetail(payload, user, contactID)` em `src/utilities/people/personDetail.ts` (novo; importa `mergePeopleSources`/`scopePeopleRows` de `./peopleData`). Lê as três fontes com `where: { contact: { equals } }` e **os mesmos selects/shapes do `loadPeopleListPageData`** (leadership com `overrideAccess: false`; staff com `overrideAccess: true` e o MESMO comentário de justificativa — `contact` é identity-gated, o escopo é provado pelo merge). Depois: merge → scope (advisor → `getAdvisorMunicipalityIds`) → resolve nomes de assessorado (só para a pessoa) → carrega supporters → resolve nomes de municípios via `loadMunicipalityPortfolioIndex()` + `resolvedPortfolioEntriesById` (cached, admin bypass justificado — dados de referência). Retorna `null` quando a pessoa não existe em nenhuma capacidade ou está fora do escopo → `notFound()`. +- **D3 — Escopo (contrato inegociável da intenção).** O view model do detalhe = `PeopleRowViewModel` estendido. O escopo do assessor é o do merge (`scopePeopleRows`), nunca o contrário. Leitura de supporters adicionalmente pelo access da collection (`overrideAccess: false, user` — advisor vê só municípios da carteira): camada dupla, nunca alarga. +- **D4 — Seção Apoiador.** Query `supporter` por contact; resumo por registro: fonte (`supporterSourceLabels`), município (nome), intenção de voto (label `supporterVoteIntentionLabels`, só quando `voteIntentionConsentedAt` — convenção do detalhe de apoiador), criado em. Zero registros → seção não renderiza. Sem blocos de texto de Consent na página (o consent em tela continua nas superfícies de apoiador; aqui é resumo de leitura já autorizada pela collection). +- **D5 — Composição de seções.** Um módulo `PersonDetailSections.tsx` em `components/campaign/people/`: shell compartilhado `PersonSectionCard` (ícone + título + badge trailing) + `PersonFichaSection` (nome/partido/contato/base + pills de capacidade "o que a pessoa é") + seções por capacidade + `PersonActionsSection`. Condicional na página: `leadershipID !== null`, `deputyID !== null`, `staff.length > 0`, `assessoradoNames.length > 0`, supporters > 0. _Rejeitada:_ um componente monolítico de detalhe (página deixa de ser fina); um "registro de capacidades" genérico (5 seções de leitura não são arquitetura de plugin). +- **D6 — Ações.** Reuso direto do padrão da lista: WhatsApp (`whatsAppHrefForPhone` + `Button asChild`, disabled sem telefone), `LeadershipInviteRowAction` (só liderança), `DeletePersonButton` com **prop opcional `onDeleted`** — no detalhe navega para `/campanha/pessoas` após o delete (sem isso o usuário fica preso num 404). _Rejeitadas:_ envolver o delete num client component do detalhe (duplicação); deixar como está (tela morta pós-delete). +- **D7 — Chrome/metadata.** `SetCampaignPageChrome { title: 'Pessoa', subtitle: name }` + `generateMetadata` = `campaignPageMetadata({ title: 'Pessoa', subtitle: name })` — padrão exato de `liderancas/[id]`. +- **D8 — Chips de município do detalhe.** Componente read-only próprio (`PersonMunicipalityChips`, `Badge` outline, mesmo visual da célula da lista), via índice resolvido. _Decisão barata adiada:_ extrair `PeopleMunicipalityCell` da lista para compartilhar — a C116 a transforma em chips internos ao input; o detalhe é read-only; futuros divergentes, extração hoje criaria componente que a C116 deleta. + +### Componentes / mudanças + +- **`loadPersonDetail`** (`src/utilities/people/personDetail.ts`): o loader do detalhe; reusa merge/scope; novo: supporters por contact + nomes de assessorado por pessoa. +- **`pessoas/[id]/page.tsx`** (rota nova): gate staff, metadata, loader, notFound, monta seções. +- **`PersonDetailSections.tsx`** (`src/components/campaign/people/`): ficha + 5 seções + ações; shell `PersonSectionCard`. +- **`DeletePersonButton`**: +1 prop opcional (`onDeleted`) — lista intocada. +- **Migration:** sem migration (ficha = `Contact` + capacidades existentes; nada de schema novo). +- **Access / Consent:** nenhum access novo — o loader herda o access de cada collection dona + o merge; sem chave de Consent nova (seção Apoiador lê só o que o access da collection já autoriza). +- **UI:** Impeccable **C** (fluxo novo — rota de detalhe + composição por capacidade): shape → craft → critique → polish. Shells da casa (`CampaignPageShell`, `SetCampaignPageChrome`, `Badge`, `Button`); sem componente de lista novo. + +### Dados → forma (se aplicável) + +- Sem dados analíticos (a intenção corta KPI/votos). A única forma numérica é a contagem de municípios em chips quando excede o colapso "+X" — mesmo padrão da lista. Sem pergunta 3 de data-presentation. + +## Fases verificáveis + +1. **Tracer (server)** — `loadPersonDetail` + rota mínima (ficha + seções sem polish) + `notFound`/metadata. Verificação: int spec da matriz de escopo verde. +2. **UI (shape → craft → critique → polish)** — seções por capacidade, pills, chips, ações reusadas, estados (sem telefone, sem capacidade, assessor). +3. **Gates** — int (matriz de escopo + supporters) e e2e (`campaignPeople.e2e.spec.ts`: coordinator abre detalhe por URL e vê seções certas; pessoa só-dobradinha sem bloco de liderança; leader redirecionado). Depois: `tsc`, `lint`, `format`, `knip`, `cycles`, unit+int, `build`; entrada no CHANGELOG; `pnpm push` → PR. + +## Rabbit holes / Não escopo (engenharia) + +- Registro de capacidades / sistema de plugins de seção — 5 seções read-only não justificam abstração. +- Edição na página (dono é a C116, in-place na lista); escala para "mini-admin". +- Migrar `/campanha/liderancas/[id]` para a rota nova — v1 convive (decisão assumida da intenção); seção de liderança linka para o detalhe rico (votos/ações vivem lá). +- Votos/estimativas na ficha — fora (a intenção corta); a assimetria atual fica onde está. +- Mostrar texto de Consent na seção Apoiador — as superfícies de apoiador já o mostram; aqui é resumo autorizado pelo access. +- Extrair `PeopleMunicipalityCell` da lista — C116 reescreve a célula; divergente do detalhe read-only. + +## Riscos e mitigação + +- **Divergência de escopo assessor entre lista e detalhe** — mitigado por construção: mesmo merge, mesmos selects, mesma função de scope; matriz pinada no int spec. +- **Apoiador vazar município fora da carteira do assessor** — o access da collection corta na query (`overrideAccess: false`); int test pinando o corte. +- **Convivência com C116** — contrato do link fixado: `/campanha/pessoas/` (D1). C116 linka para liderança enquanto C118 não existe; se C118 entrar primeiro, C116 aponta para cá. Sem overlap de arquivos (C116 mexe na célula da lista; C118 na rota nova). Se houver conflito de merge, é trivial (uma linha). +- **`overrideAccess: true` na fonte staff** — o MESMO bypass já justificado na lista (identity-gated + escopo pelo merge); comentário replicado. +- **Pessoa sem nenhuma capacidade digitada à mão na URL** → loader retorna `null` → `notFound()` — rota honesta, sem lookup de Contact solto (que vazaria fichas admin-gated). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (rota staff, seções por capacidade, ausentes não aparecem, escopo preservado, sem migration, liderança não regride) +- [x] Invariantes AGENTS/engineering-standards (Local API `user` → `overrideAccess: false` exceto bypass justificado; leader lockdown; copy pt-BR / identificadores em inglês) +- [ ] Testes de domínio previstos: `tests/int/personDetail.int.spec.ts` (matriz de escopo: unrestricted completo; advisor na carteira vê; advisor fora não vê → null; corte de supporter fora da carteira; pessoa só-staff visível só a unrestricted; contato sem capacidade → null) + e2e da rota diff --git a/src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx new file mode 100644 index 00000000..e4ab7f81 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx @@ -0,0 +1,58 @@ +import config from '@payload-config' +import { notFound } from 'next/navigation' +import { getPayload } from 'payload' + +import { PersonDetailSections } from '@/components/campaign/people/PersonDetailSections' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' +import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles' +import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' +import { loadPersonDetail } from '@/utilities/people/personDetail' + +export async function generateMetadata({ params }: PersonDetailPageProps) { + const { id } = await params + if (!/^[1-9]\d*$/.test(id)) return campaignPageMetadata({ title: 'Pessoa' }) + + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const person = await loadPersonDetail(payload, user, Number(id)) + if (!person) return campaignPageMetadata({ title: 'Pessoa' }) + + return campaignPageMetadata({ title: 'Pessoa', subtitle: person.name }) +} + +type PersonDetailPageProps = { + params: Promise<{ id: string }> +} + +export default async function PersonDetailPage({ params }: PersonDetailPageProps) { + const { id } = await params + if (!/^[1-9]\d*$/.test(id)) notFound() + + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const person = await loadPersonDetail(payload, user, Number(id)) + if (!person) notFound() + + const municipalityIndex = resolvedPortfolioEntriesById(await loadMunicipalityPortfolioIndex()) + + return ( + + + + + ) +} diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx index 6da90182..d6a0ef12 100644 --- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx @@ -179,9 +179,7 @@ const peopleColumns = ({ value={row.name} label="Nome" formAction={updatePersonContactFormAction} - {...(row.leadershipID !== null - ? { href: `/campanha/liderancas/${row.leadershipID}` } - : {})} + href={`/campanha/pessoas/${row.contactID}`} permanent /> {row.party ? ({row.party}) : null} diff --git a/src/components/campaign/people/DeletePersonButton.tsx b/src/components/campaign/people/DeletePersonButton.tsx index b5589d3c..92f9e109 100644 --- a/src/components/campaign/people/DeletePersonButton.tsx +++ b/src/components/campaign/people/DeletePersonButton.tsx @@ -31,6 +31,12 @@ import type { PersonDeleteManifest } from '@/utilities/people/personDelete' type DeletePersonButtonProps = { personName: string contactId: number + /** + * Where to go after the destructive cascade succeeds. The people list keeps + * the default (`router.refresh()`); detail pages that show the person + * navigate away — the ficha no longer exists. + */ + deletedHref?: string } type ManifestState = @@ -59,7 +65,11 @@ const mapDeleteError = (error: unknown): string => { * person protected; the ficha is deleted or anonymized (LGPD tombstone) * depending on public joins. */ -export const DeletePersonButton = ({ personName, contactId }: DeletePersonButtonProps) => { +export const DeletePersonButton = ({ + personName, + contactId, + deletedHref, +}: DeletePersonButtonProps) => { const router = useRouter() const [manifest, setManifest] = useState({ kind: 'idle' }) const [deleting, setDeleting] = useState(false) @@ -98,7 +108,11 @@ export const DeletePersonButton = ({ personName, contactId }: DeletePersonButton ? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.' : 'Pessoa apagada.', ) - router.refresh() + if (deletedHref) { + router.push(deletedHref) + } else { + router.refresh() + } } catch (error) { setDeleteError(mapDeleteError(error)) } finally { diff --git a/src/components/campaign/people/PersonDetailSections.tsx b/src/components/campaign/people/PersonDetailSections.tsx new file mode 100644 index 00000000..26f99b5d --- /dev/null +++ b/src/components/campaign/people/PersonDetailSections.tsx @@ -0,0 +1,368 @@ +import { + ArrowRightIcon, + BriefcaseIcon, + FlagIcon, + HeartHandshakeIcon, + LandmarkIcon, + MessageCircleIcon, + UserIcon, + UsersIcon, + WrenchIcon, +} from 'lucide-react' +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction' +import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge' +import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import type { ResolvedPortfolioEntry } from '@/lib/municipalityPortfolio' +import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' +import { campaignRoleLabels } from '@/utilities/campaignUserProfile' +import type { PersonDetailViewModel } from '@/utilities/people/personDetail' +import { + supporterSourceLabels, + supporterVoteIntentionLabels, +} from '@/utilities/supporter/supporterUi' + +/** + * C118 — the person detail, mounted by capacity: each section renders only + * when the ficha actually has that papel (liderança, dobradinha, assessora, + * assessorado, apoiador). The page stays thin — the composition rule lives + * here, next to the cards. Read-only by design: writing stays in the list + * (C116) and the surfaces that own each papel. + */ + +const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }) + +const PersonSectionCard = ({ + sectionId, + icon, + title, + trailing, + children, +}: { + /** ASCII stable id (aria-labelledby + CSS): `person-section-`. */ + sectionId: string + icon: ReactNode + title: string + trailing?: ReactNode + children: ReactNode +}) => ( +
+
+ {icon} +

+ {title} +

+ {trailing} +
+ {children} +
+) + +const PersonMunicipalityChips = ({ + ids, + index, +}: { + ids: readonly number[] + index: ReadonlyMap +}) => { + if (!ids.length) return — + const names = ids + .map((id) => index.get(id)?.name) + .filter((name): name is string => name !== undefined) + if (!names.length) return — + const visible = names.slice(0, 2) + const rest = names.length - visible.length + return ( +
+ {visible.map((name) => ( + + {name} + + ))} + {rest > 0 ? ( + + +{rest} + + ) : null} +
+ ) +} + +const FichaField = ({ label, value }: { label: string; value: string | null }) => ( +
+
{label}
+
+ {value ?? —} +
+
+) + +const PersonFichaSection = ({ person }: { person: PersonDetailViewModel }) => { + const capacityPills: string[] = [] + if (person.leadershipID !== null) capacityPills.push('Liderança') + if (person.deputyID !== null) capacityPills.push('Dobradinha') + if (person.staff.length > 0) capacityPills.push('Assessora') + if (person.supporters.length > 0) capacityPills.push('Apoiador') + + return ( + + ) +} + +const PersonLeadershipSection = ({ + person, + municipalityIndex, +}: { + person: PersonDetailViewModel + municipalityIndex: ReadonlyMap +}) => ( + +) + +const PersonDeputySection = ({ + person, + municipalityIndex, +}: { + person: PersonDetailViewModel + municipalityIndex: ReadonlyMap +}) => ( + +) + +const PersonAssessoraSection = ({ + person, + municipalityIndex, +}: { + person: PersonDetailViewModel + municipalityIndex: ReadonlyMap +}) => ( + +) + +const PersonAssessoradoSection = ({ names }: { names: string[] }) => ( + +) + +const PersonSupporterSection = ({ + person, + municipalityIndex, +}: { + person: PersonDetailViewModel + municipalityIndex: ReadonlyMap +}) => ( + +) + +const PersonActionsSection = ({ + person, + canDelete, +}: { + person: PersonDetailViewModel + canDelete: boolean +}) => { + const whatsAppHref = whatsAppHrefForPhone(person.phone) + const hasActions = person.leadershipID !== null || whatsAppHref !== null || canDelete + if (!hasActions) return null + return ( + + ) +} + +export const PersonDetailSections = ({ + person, + municipalityIndex, + canDelete, +}: { + person: PersonDetailViewModel + municipalityIndex: ReadonlyMap + canDelete: boolean +}) => ( +
+ + {person.leadershipID !== null ? ( + + ) : null} + {person.deputyID !== null ? ( + + ) : null} + {person.staff.length > 0 ? ( + + ) : null} + {person.assessoradoNames.length > 0 ? ( + + ) : null} + {person.supporters.length > 0 ? ( + + ) : null} + +
+) diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index a903ae2e..8134fbd4 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -79,7 +79,7 @@ export type MergedPerson = Omit & { deputyAdvisorIDs: number[] } -type PeopleLeadershipSource = { +export type PeopleLeadershipSource = { id: number contactID: number name: string @@ -92,7 +92,7 @@ type PeopleLeadershipSource = { advisorIDs: number[] } -type PeopleDeputySource = { +export type PeopleDeputySource = { id: number contactID: number name: string @@ -104,7 +104,7 @@ type PeopleDeputySource = { advisorIDs: number[] } -type PeopleStaffSource = { +export type PeopleStaffSource = { id: number contactID: number name: string @@ -124,6 +124,96 @@ export type PeopleMergeSources = { const sortedDistinct = (values: Iterable): number[] => [...new Set(values)].sort((left, right) => left - right) +/** + * Doc → source mappers shared by the list and the person detail (C118): the + * capacity definition must never diverge between the two surfaces, so the + * transforms (and the role filter of the staff source) live here once. + */ +export const toPeopleLeadershipSource = (doc: Leadership): PeopleLeadershipSource => { + const contact = contactSummary(doc.contact) + return { + id: doc.id, + contactID: contact.id, + name: contact.name, + phone: contact.phone, + email: contact.email, + city: contact.city, + municipalityIDs: uniqueRelationshipIds(doc.municipalities), + supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null, + hasAppAccess: relationshipId(doc.user) !== null, + advisorIDs: uniqueRelationshipIds(doc.advisors), + } +} + +export const toPeopleDeputySource = ( + doc: StateDeputy, + municipalityIdsByDeputy: ReadonlyMap, +): PeopleDeputySource => { + const contact = contactSummary(doc.contact) + return { + id: doc.id, + contactID: contact.id, + name: contact.name, + phone: contact.phone, + email: contact.email, + city: contact.city, + party: doc.party ?? null, + municipalityIDs: municipalityIdsByDeputy.get(doc.id) ?? [], + advisorIDs: uniqueRelationshipIds(doc.advisors), + } +} + +export const toPeopleStaffSource = ( + doc: CampaignUser, + municipalityIdsByStaff: ReadonlyMap, +): PeopleStaffSource => { + const contact = contactSummary(doc.contact) + return { + id: doc.id, + contactID: contact.id, + name: contact.name, + phone: contact.phone, + email: contact.email, + city: contact.city, + role: doc.role, + municipalityIDs: municipalityIdsByStaff.get(doc.id) ?? [], + } +} + +/** Dedupe + pt-BR sort of the assessed-by advisors (the Assessorado capacity). */ +export const resolveAssessorados = ( + advisorIDs: readonly number[], + namesById: ReadonlyMap, +): Array<{ id: number; name: string }> => + [...new Set(advisorIDs)] + .map((id) => ({ id, name: namesById.get(id) })) + .filter((pair): pair is { id: number; name: string } => pair.name !== undefined) + .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) + +/** Merged person → list/detail row: same fields, no accidental drift. */ +export const toPeopleRowViewModel = ( + person: MergedPerson, + assessorados: ReadonlyArray<{ id: number; name: string }>, +): PeopleRowViewModel => ({ + contactID: person.contactID, + name: person.name, + phone: person.phone, + email: person.email, + city: person.city, + party: person.party, + leadershipID: person.leadershipID, + leadershipMunicipalityIDs: person.leadershipMunicipalityIDs, + supportStatus: person.supportStatus, + hasAppAccess: person.hasAppAccess, + deputyID: person.deputyID, + deputyMunicipalityIDs: person.deputyMunicipalityIDs, + staff: person.staff, + assessoraMunicipalityIDs: person.assessoraMunicipalityIDs, + capacityMunicipalityIDs: person.capacityMunicipalityIDs, + assessoradoNames: assessorados.map((pair) => pair.name), + assessorados: [...assessorados], +}) + /** * One row per `contactID`. The ficha fields come from the first source that * carries them (leadership, then dobradinha, then staff — same ficha, so the @@ -449,51 +539,16 @@ export const loadPeopleListPageData = async ( ]) const leaderships: PeopleLeadershipSource[] = (leadershipResult.docs as Leadership[]).map( - (doc) => { - const contact = contactSummary(doc.contact) - return { - id: doc.id, - contactID: contact.id, - name: contact.name, - phone: contact.phone, - email: contact.email, - city: contact.city, - municipalityIDs: uniqueRelationshipIds(doc.municipalities), - supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null, - hasAppAccess: relationshipId(doc.user) !== null, - advisorIDs: uniqueRelationshipIds(doc.advisors), - } - }, + toPeopleLeadershipSource, ) - const deputies: PeopleDeputySource[] = (deputyResult.docs as StateDeputy[]).map((doc) => { - const contact = contactSummary(doc.contact) - return { - id: doc.id, - contactID: contact.id, - name: contact.name, - phone: contact.phone, - email: contact.email, - city: contact.city, - party: doc.party ?? null, - municipalityIDs: deputyMunicipalityIdsByDeputy.get(doc.id) ?? [], - advisorIDs: uniqueRelationshipIds(doc.advisors), - } - }) + const deputies: PeopleDeputySource[] = (deputyResult.docs as StateDeputy[]).map((doc) => + toPeopleDeputySource(doc, deputyMunicipalityIdsByDeputy), + ) - const staff: PeopleStaffSource[] = (staffResult.docs as CampaignUser[]).map((doc) => { - const contact = contactSummary(doc.contact) - return { - id: doc.id, - contactID: contact.id, - name: contact.name, - phone: contact.phone, - email: contact.email, - city: contact.city, - role: doc.role, - municipalityIDs: staffMunicipalityIdsByStaff.get(doc.id) ?? [], - } - }) + const staff: PeopleStaffSource[] = (staffResult.docs as CampaignUser[]).map((doc) => + toPeopleStaffSource(doc, staffMunicipalityIdsByStaff), + ) const merged = mergePeopleSources({ leaderships, deputies, staff }) const accessibleMunicipalityIds = @@ -515,32 +570,15 @@ export const loadPeopleListPageData = async ( } const advisorNames = await loadCampaignUserNamesByIds(payload, [...advisorIDs]) - const rows: PeopleRowViewModel[] = pageRows.map((person) => { - const advisorIDs = [...new Set([...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs])] - const assessorados = advisorIDs - .map((id) => ({ id, name: advisorNames.get(id) })) - .filter((pair): pair is { id: number; name: string } => pair.name !== undefined) - .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) - return { - contactID: person.contactID, - name: person.name, - phone: person.phone, - email: person.email, - city: person.city, - party: person.party, - leadershipID: person.leadershipID, - leadershipMunicipalityIDs: person.leadershipMunicipalityIDs, - supportStatus: person.supportStatus, - hasAppAccess: person.hasAppAccess, - deputyID: person.deputyID, - deputyMunicipalityIDs: person.deputyMunicipalityIDs, - staff: person.staff, - assessoraMunicipalityIDs: person.assessoraMunicipalityIDs, - capacityMunicipalityIDs: person.capacityMunicipalityIDs, - assessoradoNames: assessorados.map((pair) => pair.name), - assessorados, - } - }) + const rows: PeopleRowViewModel[] = pageRows.map((person) => + toPeopleRowViewModel( + person, + resolveAssessorados( + [...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs], + advisorNames, + ), + ), + ) return { rows, totalDocs, totalPages, filterFacets } } diff --git a/src/utilities/people/personDetail.ts b/src/utilities/people/personDetail.ts new file mode 100644 index 00000000..74ca19a0 --- /dev/null +++ b/src/utilities/people/personDetail.ts @@ -0,0 +1,170 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { relationshipId } from '@/lib/relationship' +import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +import type { CampaignUser, Leadership, StateDeputy, Supporter } from '@/payload-types' +import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData' +import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess' +import { loadCampaignUserNamesByIds } from '@/utilities/loadNamesByIds' +import { + mergePeopleSources, + resolveAssessorados, + scopePeopleRows, + toPeopleDeputySource, + toPeopleLeadershipSource, + toPeopleRowViewModel, + toPeopleStaffSource, + type PeopleRowViewModel, +} from '@/utilities/people/peopleData' +import { buildPeopleStaffSourceWhere } from '@/utilities/people/peopleListUrl' +import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData' + +/** + * The person detail (C118): the same merge-by-`Contact` as the unified list, + * narrowed to ONE ficha, plus the supporter records that the list row does not + * carry. The scope contract is the LIST'S, verbatim — `scopePeopleRows` with + * the advisor's carteira — so a detail can never show a person the list would + * not: an advisor opens only people with at least one capacity municipality in + * her carteira, unrestricted actors open anyone with any role. + * + * The staff source reads with the same justified `overrideAccess` as + * `loadPeopleListPageData` (the `contact` field is identity-gated and would be + * cut from the depth-1 populate otherwise); the merge is what proves the + * actor's scope. The supporter read goes through the collection's own access + * (`overrideAccess: false`) — a second, narrower layer, never a widening one: + * an advisor sees only supporter rows in her municipalities. + * + * Municipality names are NOT resolved here: the page renders every section + * through the shared portfolio index (same "ids only" contract as the list + * columns), so there is one source of truth for names. + */ +type PersonSupporterSummary = { + id: number + source: Supporter['source'] + municipalityID: number | null + voteIntention: SupporterVoteIntention | null + hasVoteIntentionConsent: boolean + createdAt: string +} + +export type PersonDetailViewModel = PeopleRowViewModel & { + supporters: PersonSupporterSummary[] +} + +const supporterDetailSelect = { + source: true, + municipality: true, + voteIntention: true, + voteIntentionConsentedAt: true, + createdAt: true, +} as const + +export const loadPersonDetail = async ( + payload: Payload, + user: CampaignUser, + contactID: number, +): Promise => { + if (!isCampaignStaff(user)) return null + + const [leadershipResult, deputyResult, staffResult, supporterResult] = await Promise.all([ + payload.find({ + collection: 'leadership', + where: { contact: { equals: contactID } }, + depth: 1, + limit: 0, + pagination: false, + select: { + contact: true, + municipalities: true, + advisors: true, + supportStatus: true, + user: true, + }, + user, + overrideAccess: false, + }), + payload.find({ + collection: 'stateDeputy', + where: { contact: { equals: contactID } }, + depth: 1, + limit: 0, + pagination: false, + select: { contact: true, slug: true, party: true, advisors: true }, + user, + overrideAccess: false, + }), + payload.find({ + collection: 'campaignUser', + // The list's own staff where (`role in advisor|coordinator|candidate` + + // `contact exists`) — a leader account linked to the ficha must never + // light the Assessora capacity here, exactly as it never does in the list. + where: { + and: [buildPeopleStaffSourceWhere({ page: 1 }), { contact: { equals: contactID } }], + }, + depth: 1, + limit: 0, + pagination: false, + select: { name: true, role: true, contact: true }, + // Same justified bypass as `loadPeopleListPageData`: `contact` is + // identity-gated and the merge below proves the actor's scope. + overrideAccess: true, + }), + payload.find({ + collection: 'supporter', + where: { contact: { equals: contactID } }, + depth: 1, + limit: 0, + pagination: false, + select: supporterDetailSelect, + user, + overrideAccess: false, + }), + ]) + + const [deputyMunicipalityIdsByDeputy, staffMunicipalityIdsByStaff] = await Promise.all([ + municipalityIdsByStateDeputyIds( + payload, + deputyResult.docs.map((doc) => doc.id), + ), + municipalityIdsByAdvisorIds( + payload, + staffResult.docs.map((doc) => doc.id), + ), + ]) + + const leaderships = (leadershipResult.docs as Leadership[]).map(toPeopleLeadershipSource) + + const deputies = (deputyResult.docs as StateDeputy[]).map((doc) => + toPeopleDeputySource(doc, deputyMunicipalityIdsByDeputy), + ) + + const staff = (staffResult.docs as CampaignUser[]).map((doc) => + toPeopleStaffSource(doc, staffMunicipalityIdsByStaff), + ) + + const merged = mergePeopleSources({ leaderships, deputies, staff }) + const accessibleMunicipalityIds = + user.role === 'advisor' ? new Set(await getAdvisorMunicipalityIds(payload, user.id)) : null + const [person] = scopePeopleRows(merged, accessibleMunicipalityIds) + if (!person) return null + + const advisorIDs = [...new Set([...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs])] + const advisorNames = await loadCampaignUserNamesByIds(payload, advisorIDs) + const assessorados = resolveAssessorados(advisorIDs, advisorNames) + + const supporters: PersonSupporterSummary[] = (supporterResult.docs as Supporter[]).map((doc) => ({ + id: doc.id, + source: doc.source, + municipalityID: relationshipId(doc.municipality), + voteIntention: doc.voteIntention ?? null, + hasVoteIntentionConsent: Boolean(doc.voteIntentionConsentedAt), + createdAt: doc.createdAt, + })) + + return { + ...toPeopleRowViewModel(person, assessorados), + supporters, + } +} diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 1ff7ba5e..40bbb785 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -39,6 +39,27 @@ test.describe('Pessoas — lista unificada', () => { await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible() }) + test('the name link in the list opens the person detail (C118 entry point)', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.claimMunicipality() + const { contactId, contactName } = await fixtures.createStaffLeadership({ + namePrefix: 'Nome Link Detalhe', + municipalities: [municipality], + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/pessoas') + + await page.getByRole('link', { name: contactName, exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`)) + await expect(campaignPageChrome(page, 'Pessoa')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Liderança' })).toBeVisible() + }) + test('leader cannot open the people page', async ({ campaign, page }) => { const { fixtures } = campaign const phone = fixtures.phone() @@ -54,6 +75,125 @@ test.describe('Pessoas — lista unificada', () => { await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible() }) + test('leader cannot open the person detail route', async ({ campaign, page }) => { + const { fixtures } = campaign + const phone = fixtures.phone() + const leader = await fixtures.createCampaignUser('leader', { + name: fixtures.value('Liderança Detalhe Sem Acesso'), + username: phone, + }) + + await campaign.login(page, phone, leader.password) + await page.goto('/campanha/pessoas/99999') + + await expect(page).toHaveURL(/\/campanha\/contatos/) + await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible() + }) + + test('coordinator opens the person detail and sees the sections of her capacities', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.claimMunicipality() + + const { contactId, contactName, leadershipId } = await fixtures.createStaffLeadership({ + namePrefix: 'Liderança Detalhe', + municipalities: [municipality], + supportStatus: 'engajado', + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto(`/campanha/pessoas/${contactId}`) + + await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`)) + await expect(campaignPageChrome(page, 'Pessoa')).toBeVisible() + await expect( + page + .locator('[data-slot="campaign-page-chrome"]') + .filter({ visible: true }) + .getByText(contactName), + ).toBeVisible() + + // Sections mounted by capacity: a leadership-only person shows Ficha + + // Liderança, never an empty Dobradinha block. + await expect(page.getByRole('heading', { name: 'Ficha' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Liderança' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Dobradinha' })).not.toBeVisible() + await expect(page.getByRole('heading', { name: 'Assessorado' })).not.toBeVisible() + + // The leadership section links to the rich existing detail (v1 coexistence). + await expect(page.getByRole('link', { name: 'Abrir detalhe de liderança' })).toHaveAttribute( + 'href', + `/campanha/liderancas/${leadershipId}`, + ) + await expect( + page.getByRole('region', { name: 'Liderança' }).getByText('Engajado'), + ).toBeVisible() + + // Actions: WhatsApp (phone exists) + delete (coordinator); invite for leaders. + await expect(page.getByRole('link', { name: 'Enviar WhatsApp' })).toBeVisible() + await expect(page.getByRole('button', { name: /Convidar/ })).toBeVisible() + await expect(page.getByRole('button', { name: `Apagar pessoa ${contactName}` })).toBeVisible() + }) + + test('coordinator sees dobradinha and apoiador sections only for a person who has them', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const deputyMunicipality = await fixtures.claimMunicipality() + const supporterMunicipality = await fixtures.claimMunicipality() + + const contact = await fixtures.payload.create({ + collection: 'contact', + data: { + name: fixtures.value('Dobradinha Detalhe'), + phones: [{ value: fixtures.phone() }], + state: 'BA', + city: 'Salvador', + }, + depth: 0, + }) + const deputy = await fixtures.payload.create({ + collection: 'stateDeputy', + data: { contact: contact.id, party: 'PCdoB', slug: fixtures.value('dobradinha-detalhe') }, + depth: 0, + }) + await fixtures.payload.update({ + collection: 'municipality', + id: deputyMunicipality.id, + data: { stateDeputies: [deputy.id] }, + depth: 0, + overrideAccess: true, + }) + fixtures.touchMunicipality(deputyMunicipality.id) + await fixtures.payload.create({ + collection: 'supporter', + data: { + contact: contact.id, + municipality: supporterMunicipality.id, + source: 'evento', + voteIntention: 'certo', + }, + depth: 0, + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto(`/campanha/pessoas/${contact.id}`) + + await expect(page.getByRole('heading', { name: 'Ficha' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Dobradinha' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Apoiador' })).toBeVisible() + await expect(page.getByRole('region', { name: 'Dobradinha' }).getByText('PCdoB')).toBeVisible() + await expect(page.getByRole('region', { name: 'Apoiador' }).getByText('Certo')).toBeVisible() + // No leadership capacity → no leadership block, no invite. + await expect(page.getByRole('heading', { name: 'Liderança' })).not.toBeVisible() + await expect(page.getByRole('button', { name: /Convidar/ })).not.toBeVisible() + }) + test('staff sorts by a column header and filters absence via the omnibox (C117)', async ({ campaign, page, @@ -130,3 +270,4 @@ test.describe('Pessoas — lista unificada', () => { await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeHidden() }) }) +}) diff --git a/tests/int/personDetail.int.spec.ts b/tests/int/personDetail.int.spec.ts new file mode 100644 index 00000000..ca060c3e --- /dev/null +++ b/tests/int/personDetail.int.spec.ts @@ -0,0 +1,218 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { loadPersonDetail } from '@/utilities/people/personDetail' + +import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('C118 — detalhe da pessoa (seções por capacidade, escopo do merge da lista)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('unrestricted vê a ficha inteira: liderança + dobradinha + staff + assessorado + apoiador', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const leadershipMunicipality = await fixtures.getMunicipality() + const supporterMunicipality = await fixtures.getMunicipality() + + const mariaName = fixtures.personName('Maria de Jesus') + const contact = await fixtures.createContact({ name: mariaName }) + const advisor = await fixtures.createCampaignUser('advisor', { + name: fixtures.value('Assessor Maria'), + }) + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [leadershipMunicipality], + supportStatus: 'engajado', + }) + await payload.update({ + collection: 'leadership', + id: leadership.id, + data: { advisors: [advisor.id] }, + depth: 0, + overrideAccess: true, + }) + const deputy = await fixtures.createStateDeputy({ contact, party: 'PCdoB' }) + await payload.update({ + collection: 'municipality', + id: leadershipMunicipality.id, + data: { stateDeputies: [deputy.id] }, + depth: 0, + overrideAccess: true, + }) + // Phone reuse folds the account into the same ficha (staff capacity). + const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone }) + const supporter = await fixtures.createSupporter({ + contact, + municipality: supporterMunicipality, + source: 'manual', + voteIntention: 'certo', + }) + + const person = await loadPersonDetail(payload, coordinator, relationId(contact)) + expect(person).not.toBeNull() + expect(person).toMatchObject({ + name: mariaName, + party: 'PCdoB', + leadershipID: leadership.id, + supportStatus: 'engajado', + deputyID: deputy.id, + }) + expect(person?.leadershipMunicipalityIDs).toContain(leadershipMunicipality.id) + expect(person?.deputyMunicipalityIDs).toEqual([leadershipMunicipality.id]) + expect(person?.staff.map((entry) => entry.id)).toContain(account.id) + expect(person?.assessoradoNames).toContain(advisor.name) + expect(person?.supporters).toEqual([ + expect.objectContaining({ + id: supporter.id, + source: 'manual', + municipalityID: supporterMunicipality.id, + voteIntention: 'certo', + hasVoteIntentionConsent: false, + }), + ]) + }) + + it('advisor vê a pessoa com capacidade na carteira', async () => { + const fixtures = campaignFixtures() + const municipality = await fixtures.getMunicipality() + const advisor = await fixtures.createCampaignUser('advisor') + await fixtures.assignMunicipalityAdvisors(municipality, [advisor]) + + const contact = await fixtures.createContact({ name: fixtures.personName('Ana Lima') }) + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [municipality], + }) + + const person = await loadPersonDetail(payload, advisor, relationId(contact)) + expect(person).not.toBeNull() + expect(person?.leadershipID).toBe(leadership.id) + }) + + it('advisor não vê a pessoa cuja única capacidade está fora da carteira', async () => { + const fixtures = campaignFixtures() + const outside = await fixtures.getMunicipality() + const carteira = await fixtures.getMunicipality() + const advisor = await fixtures.createCampaignUser('advisor') + await fixtures.assignMunicipalityAdvisors(carteira, [advisor]) + + const contact = await fixtures.createContact({ name: fixtures.personName('Fora da Carteira') }) + await fixtures.createLeadership({ contact, municipalities: [outside] }) + + expect(await loadPersonDetail(payload, advisor, relationId(contact))).toBeNull() + }) + + it('corta o apoiador de município fora da carteira do assessor', async () => { + const fixtures = campaignFixtures() + const carteira = await fixtures.getMunicipality() + const outside = await fixtures.getMunicipality() + const advisor = await fixtures.createCampaignUser('advisor') + await fixtures.assignMunicipalityAdvisors(carteira, [advisor]) + + const contact = await fixtures.createContact({ name: fixtures.personName('Apoio Misto') }) + // Dobradinha as the carteira capacity: the supporter hook forbids a + // leadership in the same municipality, so the in-scope supporter row + // needs a capacity that is not leadership. + const deputy = await fixtures.createStateDeputy({ contact, party: 'PT' }) + await payload.update({ + collection: 'municipality', + id: carteira.id, + data: { stateDeputies: [deputy.id] }, + depth: 0, + overrideAccess: true, + }) + const inside = await fixtures.createSupporter({ + contact, + municipality: carteira, + source: 'evento', + }) + await fixtures.createSupporter({ contact, municipality: outside, source: 'convite' }) + + const person = await loadPersonDetail(payload, advisor, relationId(contact)) + expect(person).not.toBeNull() + expect(person?.deputyID).toBe(deputy.id) + expect(person?.supporters.map((entry) => entry.id)).toEqual([inside.id]) + }) + + it('pessoa só-staff (carteira vazia) é visível a unrestricted, nunca a assessor', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const advisor = await fixtures.createCampaignUser('advisor') + await fixtures.assignMunicipalityAdvisors(municipality, [advisor]) + + const contact = await fixtures.createContact({ name: fixtures.personName('Só Assessora') }) + const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone }) + + const asCoordinator = await loadPersonDetail(payload, coordinator, relationId(contact)) + expect(asCoordinator).not.toBeNull() + expect(asCoordinator?.staff.map((entry) => entry.id)).toContain(account.id) + expect(asCoordinator?.assessoraMunicipalityIDs).toEqual([]) + expect(asCoordinator?.supportStatus).toBeNull() + + expect(await loadPersonDetail(payload, advisor, relationId(contact))).toBeNull() + }) + + it('contato sem nenhuma capacidade e leader não resolvem detalhe', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const leader = await fixtures.createCampaignUser('leader') + + const bareContact = await fixtures.createContact({ name: fixtures.personName('Contato Solto') }) + expect(await loadPersonDetail(payload, coordinator, relationId(bareContact))).toBeNull() + + const contact = await fixtures.createContact({ name: fixtures.personName('Líder de Teste') }) + await fixtures.createLeadership({ contact, municipalities: [await fixtures.getMunicipality()] }) + expect(await loadPersonDetail(payload, leader, relationId(contact))).toBeNull() + }) + + it('conta leader na ficha não acende a capacidade Assessora (filtro de papel da lista)', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + + const contact = await fixtures.createContact({ name: fixtures.personName('Liderança Leader') }) + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + // Phone reuse folds the leader account into the same ficha — but the staff + // source contract (`role in advisor|coordinator|candidate`) must cut it. + await fixtures.createCampaignUser('leader', { phone: contact.phone }) + + const person = await loadPersonDetail(payload, coordinator, relationId(contact)) + expect(person).not.toBeNull() + expect(person?.staff).toEqual([]) + expect(person?.assessoraMunicipalityIDs).toEqual([]) + }) + + it('apoiador sem município é visível só a unrestricted (coordinator-only por access)', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const advisor = await fixtures.createCampaignUser('advisor') + await fixtures.assignMunicipalityAdvisors(municipality, [advisor]) + + const contact = await fixtures.createContact({ name: fixtures.personName('Apoio Solto') }) + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + const supporter = await fixtures.createSupporter({ contact, source: 'convite' }) + + const asCoordinator = await loadPersonDetail(payload, coordinator, relationId(contact)) + expect(asCoordinator?.supporters.map((entry) => entry.id)).toEqual([supporter.id]) + + const asAdvisor = await loadPersonDetail(payload, advisor, relationId(contact)) + expect(asAdvisor).not.toBeNull() + expect(asAdvisor?.supporters).toEqual([]) + }) +}) From 40435731524fc948f7cbb399c3c76e305e538724 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 16:25:36 -0300 Subject: [PATCH 2/2] fix(C118): fecha describe do e2e campaignPeople (merge com C117) e formata changelog --- docs/CHANGELOG-AGENTS.md | 33 ---------------------------- tests/e2e/campaignPeople.e2e.spec.ts | 1 - 2 files changed, 34 deletions(-) diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 35108210..33aa3b2d 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,15 +4,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- -<<<<<<< HEAD **Recently resolved (2026-08-11):** **C124** (Issue #668) — tarefa de atividade fica só com **texto, responsável e concluída**: o campo `due` (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array `tasks` da `activity` (o guardrail de liderança perde a comparação de `due` no mesmo item — continua fail-closed sobre título/responsável/contagem), `taskSchema` zod, `parseTasksFormData`/`ParsedActivityTask` (formData), `ActivityFormTaskViewModel`/`ActivityTaskViewModel`, input `datetime-local` do `ActivityTaskFields` (grid `2fr_1fr`) e linha "Prazo: …" do `ActivityTaskChecklist` (subtítulo condicional + `break-words` no título — P2 dos reviewers). `doneAt` ("concluída em") permanece como derivado invisível; `taskTotal`/`taskDoneCount` intocados. **Migration** `20260811_183016_drop_activity_task_due` (gerada; diff do gerador tinha no-op de `google_calendar_sync` por snapshot stale — limpa à mão para só o `DROP COLUMN "due"` de `activity_tasks`; dados descartados conforme decidido no gate). Grep pós: zero `due` fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção [`atividades-tarefas-sem-prazo.md`](plans/atividades-tarefas-sem-prazo.md); impl [`atividades-tarefas-sem-prazo-impl.md`](plans/atividades-tarefas-sem-prazo-impl.md). -======= **Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md). ->>>>>>> 778ebb56 (feat(C118): página de detalhe da pessoa — seções montadas pelas capacidades (Closes #657)) **Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md). -======= -**Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md). **Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md). @@ -30,10 +25,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`. -**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os ``) e `AdvisorsTable` (client) passou a receber `columnVisibility` com ``/`` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md). - -**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`. - **Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). **Recently resolved (2026-08-10):** **D9** (Issue #596) — flake do consent restore do `testDatabaseLease` sob carga eliminado. **Caracterização com reprodução** (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. **Causa raiz em três camadas:** (1) a janela de operação de `withMissingInviteConsentFixture` era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) `removeOnda0ConsentAndPrivacyDb` rodava sob leases **shared** e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras `before` que o provision Onda0 podia re-textar legitimamente. **Fix (só testes, zero `src/`):** janela de operação sob lease shared (`serializeWindow`, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases **shared** + down-migration **restaura as linhas com os ids originais** dentro da janela (`restoreConsentRows` compartilhado; no-op para o resto da suíte); asserções de restore referenciam o **snapshot do fixture** (contrato real), rollback asserta id+key; `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `ensureInviteConsent` órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção [`test-database-lease-consent-flake.md`](plans/test-database-lease-consent-flake.md); impl [`test-database-lease-consent-flake-impl.md`](plans/test-database-lease-consent-flake-impl.md). @@ -71,8 +62,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **C108** (Issue #522) — sino de notificações: **abrir já lê** — `openCampaignNotifications` (action única) lista + marca tudo como lido em 1 round trip, substituindo `listCampaignNotifications` + `markAllCampaignNotificationsRead`; o badge zera na abertura sem clique e o botão "Marcar todas como lidas" deixa de existir. Painel limpo em qualquer tamanho: sem título "Notificações" visível (sr-only para a11y), sem contador, sem botão "Fechar"; desktop vira **modal central** (Dialog shadcn, X no canto superior direito, clique fora fecha — par responsivo Dialog/Drawer do C94 via `useIsMobile`); mobile segue bottom sheet com handle, safe-area e **skeleton de loading** (o painel não abre mais em branco durante o round trip). `markAllNotificationsRead` extraída para `src/utilities/notification/notificationList.ts` (owner-scoped com bypass documentado, idempotente, cap 200 mantido; int testado: marca só as próprias, deixa as do outro intactas); `readAt` saiu do contrato `NotificationListItem` (sem leitores após a remoção do destaque de não lida); copy de erro unificada em `CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE`; `router.refresh()` re-sincroniza a instância irmã do outro header só quando algo foi marcado (race cross-tab documentado); foco visível nos itens e `role="alert"` no erro. P4-D atualizado no ledger — o batch sem transação agora dispara a cada abertura (self-healing). Testes: int (owner-scope + idempotência) e unit novos do bell (dialog limpo, refresh condicional, empty/error, sheet mobile). Sem migration, access, Consent ou schema. Intenção [`sino-notificacoes-leitura-auto-modal-desktop.md`](plans/sino-notificacoes-leitura-auto-modal-desktop.md); impl [`sino-notificacoes-leitura-auto-modal-desktop-impl.md`](plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md). **Recently resolved (2026-08-10):** **B186** (Issue #525) — o Sollinha ganhou a tool **`getMunicipalityPriorities`** (`src/utilities/ai/tools/getMunicipalityPriorities.ts`): responde "quais devem ser minhas prioridades neste momento?" com **top N municípios do escopo** (coordenador = estado; assessor = portfólio; recorte por território/cidade/município), cada item com **uma linha de evidência** e o **critério de ordenação declarado na resposta** (criterio + janelaDias). **Ranking derivado na hora, 3 baldes mutuamente exclusivos** por gravidade — (1) `sinal_desfavoravel`: última atualização na janela com `ruim`/urgente/alerta de adversário (trecho do body na evidência; "última palavra decide"); (2) `estagnacao`: sem sinal há 30+ dias (default, ajustável 7–90) ou nunca; (3) `potencial`: nível N0/N1/sem nível + **top 5 por potencial do escopo** (`expectedVotes.central` 2026 com fallback nos válidos 2022 do artefato commitado, fonte rotulada na evidência). **Exclusão literal do aceite:** atualização recente `boa` e sem sinais negativos tira o município de todos os baldes; `municipality.priority: 'alta'` é fator (menção "prioritário" + tiebreak), nunca o ranking inteiro; tendência política desfavorável entra como cláusula secundária. **Frescor = E9** (`max(lastUpdateAt, lastPledgeAt)`, `municipalitySignal.ts` — divergência da hipótese B185 de só-`municipalityUpdate`, aprovada no gate: a intenção lista recência de `votePledge` como fonte). Refinamentos: `reason` filtra por balde ("só as sem atualização"), `sortBy: 'potencial'` reordena, `days`/`limit` ajustam. **Arquitetura:** ranking em módulo **puro e client-safe** (`src/utilities/municipality/municipalityPriorities.ts`, 28 unit tests sem DB) + resolver de escopo da B185 **extraído** para `src/utilities/ai/tools/aiToolScope.ts` (2º consumidor; `getPendingLeaderships` refatorado — 18 testes verdes, sem mudança de comportamento); gate staff inline (leader → `{ error: 'Leitura de prioridades de municípios negada.' }` antes de qualquer query), leituras `overrideAccess: false, user: ctx.user`, escopo vazio (assessor fora do portfólio) short-circuita antes da leitura. Seção nova no system prompt; allowlist P3-D estendida (mesma forma distinta da B185). Tool tests novos (`prioritiesTool.unit.spec.ts`, 10) — gate fail-closed, where da janela, escopo região, fallback 2022 (slugs reais do artefato), truncamento. Sem migration/access change/Consent/UI. Intenção [`sollinha-prioridades-do-momento.md`](plans/sollinha-prioridades-do-momento.md); impl [`sollinha-prioridades-do-momento-impl.md`](plans/sollinha-prioridades-do-momento-impl.md). -**Recently resolved (2026-08-10):** **B192** (Issue #533) — o Sollinha encerra respostas factuais com **follow-ups sugeridos como chips** (2–3, no mesmo slot dos chips de abertura do B191, acima do input; tocar envia como mensagem do usuário; substituídos a cada resposta; somem durante o streaming; mobile limita a 2). **Uma única chamada ao modelo:** o prompt (`systemPrompt.ts`) instrui encerrar com o bloco estável `**Sugestões de continuação:**` + lista markdown (só perguntas respondíveis pelas próprias tools, leader sem sugestão eleitoral — B180; bloco = instrução de interface, nunca citar em prosa) e o cliente extrai. **Contrato em fonte única:** `src/lib/sollinhaFollowUpSuggestions.ts` — `SOLLINHA_FOLLOW_UP_MARKER` (interpolado no prompt) + `splitSollinhaFollowUpBlock` (última ocorrência vence; body sempre mascarado do marcador em diante; itens `-/*/1./1)` aceitos, bold e duplicados limpos, teto 3; **fail-closed: <2 itens ou linha não-lista → sem chips**, resposta normal). UI: strip no render de todo text part assistant **antes** do markdown/links (bloco nunca pisca como prosa; mensagens antigas restauradas via B188 continuam mascaradas e os chips renascem por derivação); slot B191 estendido (catálogo vazio → follow-ups); tipo compartilhado renomeado `SollinhaOpeningQuestion → SollinhaChatChip`. **Strip defensivo no servidor:** `route.ts` remove o bloco das mensagens assistant ao reconverter para o modelo (próximo turno sem eco nem custo de contexto). Unit (16 casos do split) + e2e novo `campaignAiChatFollowUps.e2e.spec.ts` (wire format v7 como o mock do B187; bolha sem o bloco, chips, substituição, fail-closed, cap mobile). Sem migration/access/Consent/schema/endpoint novo. Intenção [`sollinha-follow-ups-respostas.md`](plans/sollinha-follow-ups-respostas.md); impl [`sollinha-follow-ups-respostas-impl.md`](plans/sollinha-follow-ups-respostas-impl.md). - **Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `
`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md). **Recently resolved (2026-08-09):** **C103** (Issue #504) — o formulário de criação rápida da agenda ficou **usável de ponta a ponta no celular**: no narrow (mesmo breakpoint 640px do calendário), o sheet de criação abre **do topo** (`swipeDirection="up"` na primitiva `Drawer` — que já suportava top sheet; zero mudança em `Drawer.tsx`) preenchendo `100dvh-1rem`, o conteúdo **rola** num scroll container e o rodapé ("Mais detalhes" + "Salvar") é **fixo** — verificado em smoke com teclado aberto (viewport 280px: sheet encolhe via `dvh`, conteúdo rola 187/187px e o Salvar permanece visível). O form ganhou `variant: 'popover' | 'sheet'`: no sheet, **labels viram `sr-only`** (a11y e `getByLabelText` intactos), placeholders dão contexto ("Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"), obrigatórios marcados com asterisco (título/município no placeholder, Início no trigger), campos empilhados **sem gaps com `divide-y`** e inputs/triggers sem borda (lista iOS do canvas). O **seletor de data/hora abre como bottom sheet aninhado** no mobile (`ActivityDateTimeField` ganhou `isNarrow`/`label`/`required`: conteúdo compartilhado Calendar+selects renderizado em `Drawer` com header "Início — " + "Pronto"; desktop continua popover). Props backward-compatible: `placeholder`/`className` no `StrictCombobox`, `labelClassName`/`emptyText`/`triggerClassName` no `ResponsibleMultiSelect`. Decisão do smoke: swipe handle **removido** do top sheet (primitiva o posiciona no rodapé, contradizendo o gesto de swipe-up; continua nos bottom sheets). E2E mobile novo (viewport 390×844): sheet do topo (`y ≤ 1`), Salvar visível sem scroll, picker em bottom sheet com Pronto, evento aparece após salvar; unit narrow cobre placeholders/asterisco/Pronto. Desktop visualmente inalterado (B181 segue na fila para o clip do popover ≤720px). Sem migration/access/Consent/schema. Intenção [`agenda-mobile-form-criacao-usavel.md`](plans/agenda-mobile-form-criacao-usavel.md); impl [`agenda-mobile-form-criacao-usavel-impl.md`](plans/agenda-mobile-form-criacao-usavel-impl.md). @@ -88,8 +77,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md). **Recently resolved (2026-08-10):** **C100** (Issue #495) — nova rota staff **`/campanha/pessoas`**: lista unificada de pessoas da campanha (lideranças, dobradinhas e staff) com **merge por `Contact`** — uma linha por pessoa física, capacidades como colunas (Nome com partido da dobradinha, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações). Merge em memória de 3 fontes com o where/access existente de cada domínio (`src/utilities/people/peopleData.ts` — fontes leadership/dobradinha/staff; staff source com `overrideAccess` justificado: campo `contact` identity-gated; escopo do assessor = linha com ≥1 município de capacidade na carteira; **todo staff com ficha entra, mesmo sem carteira** — gate humano 2026-08-09). Filtros na omnibox (capacidade multi OR, município, apoio, q) com URL canônica própria (`peopleListUrl.ts`, sem sort v1); **filtros salvos = 2º call site do B18** via factory compartilhada `createSavedFilterStore` (`municipalitySavedFilters` refatorado como delegado, API + storage key + eventos idênticos, suite unit existente guarda) + submenu no sidebar e no drawer "Mais". **Apagar pessoa** (`src/utilities/people/personDelete.ts` + `actions/person.ts`): manifest read-only → diálogo lista tudo (liderança c/ municípios, dobradinha c/ partido, votos, convites, apoiadores, atualizações de município, feeds de agenda, contas); cascata transacional com advisory lock `person-delete:` — ordem por FKs NOT NULL reais (pledges → invites → supporters → leadership → dobradinha → contas (hooks de passkeys/notificações) → ficha deletada ou anonimizada se signature/subscription a referenciarem); contas coordenação/candidato bloqueiam a pessoa inteira; advisor joins cascadeiam no delete da conta. UI: omnibox `edgeToEdge` (novo variant no chassis B127 — label sr-only, sem bordas, Limpar = círculo-X interno), cards mobile sem bordas com divisórias, "Salvar filtro" como icon button no header via `SetCampaignHeaderAction` (slot C94), colunas com column picker B17 (novo `CampaignListId 'pessoas'`), FABs globais intactos. Mobile: convite reusa `LeadershipInviteRowAction`; WhatsApp `whatsAppHrefForPhone`. Sem migration. Testes: unit (URL contract, merge/escopo/facets, storage factory), int (escopo assessor/unrestricted/leader, cascata completa + anonimização + conta protegida), e2e novo `campaignPeople` (smoke staff + leader redirect; pegou e corrigiu um `div` dentro de `p` no card mobile — erro de hidratação). Intenção [`pessoas-lista-unificada.md`](plans/pessoas-lista-unificada.md); impl [`pessoas-lista-unificada-impl.md`](plans/pessoas-lista-unificada-impl.md). -**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md). - **Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no ``). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md). **Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md). @@ -109,8 +96,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md). -**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md). - **Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md). **Recently resolved (2026-08-09):** **C94** (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram **um combobox omnibox** (`activityAgendaOmnibox.ts` + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar `[combobox][carrinho giro][+ nova]` (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” **saiu do card de filtros** para o **header do app** (`CampaignDesktopHeader`, **agenda-contextual**, ordem `[link][sino][IA]` do gate) via registro chaveado `SetCampaignHeaderAction` no `CampaignPageChromeContext` (slot compartilhado com C95), e virou ação de drawer no FAB mobile (`CampaignQuickAction.onAction` + `openCalendarFeed` no `CampaignQuickActionContext`) abrindo o **mesmo diálogo como sheet** responsivo (`CalendarFeedDialog` Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção [`c94-agenda-combobox-header-acoes-rapidas.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas.md); impl [`c94-agenda-combobox-header-acoes-rapidas-impl.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md). @@ -143,10 +128,6 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-03):** **B86** (Issue #22) — ações rápidas de apoiadores (staff), último catálogo do lote B80–B90: módulo `supporterQuickActions.ts` (espelha B87) com `register-supporter` → `/apoiadores/novo` (todo staff) e `import-supporters` → `/apoiadores/importar` (coordinator-only, espelha `isCampaignCoordinator`); superfície lista+detalhe numérico; `novo`/`importar` só-busca; leader fora. Sem migration/Consent. Impl [`acoes-rapidas-apoiadores-impl.md`](docs/plans/acoes-rapidas-apoiadores-impl.md); débito S1 (normalização de pathname de lista, 6+ call sites) → #353 [`escala-dry-pos-acoes-rapidas.md`](docs/plans/escala-dry-pos-acoes-rapidas.md). -**Recently resolved (2026-08-03):** **Pass 5 P1** — push de notificação de campanha após commit: `onPayloadTransactionCommit` em `withPayloadTransaction`; `createCampaignNotification` não usa mais `queueMicrotask` sob `req.transactionID`; `sendCampaignPushForNotification` soft-fail se a row sumiu. Guarda `notificationPushScheduleConventions`. - -**Recently resolved (2026-08-03):** **Pass 5 — auditoria de engenharia autônoma** — precheck solitário (pool desligado); delta desde Pass 4 = 627 arquivos / 520 commits; baseline verde (madge **859** arquivos). **0 P0**; **1 P1** — push de notificação via `queueMicrotask` sob `req.transactionID` corria antes do commit (`createCampaignNotification`) → after-commit registry + soft-fail. **0** `kind:agent-miss` abertas. Carry-forward P4-A…P4-L re-medido; ondas novas P5-A…P5-L em [`entrega-engenharia-p5.md`](plans/entrega-engenharia-p5.md). - **Recently resolved (2026-08-03):** **OPS18** (Issue #296) — promote dual plan-issue: Action `plan-issue-ready-on-main-merge.yml` no merge lê `Related #N` e promove Issues ainda aguardando plano (`canPromotePlanIssue`); idempotente com `pnpm agent:ready`. Planos: [`plan-issue-promote-dual.md`](docs/plans/plan-issue-promote-dual.md), impl [`plan-issue-promote-dual-impl.md`](docs/plans/plan-issue-promote-dual-impl.md). **Recently resolved (2026-08-02):** **OPS17** (Issue #292) — ciclo plan-issue: gate pós-overview antes de Issue/PR; `agent:register --plan` nasce `blocked`; após plano em `main`, `pnpm agent:ready` promove a `ready`. Planos: [`plan-issue-lifecycle-ready.md`](docs/plans/plan-issue-lifecycle-ready.md), impl [`plan-issue-lifecycle-ready-impl.md`](docs/plans/plan-issue-lifecycle-ready-impl.md). Action dual no merge = OPS18 (#296). @@ -163,22 +144,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-01):** **B102** (Issue #129) — crash na busca do bottom drawer (focus/digitação): `TooltipProvider` do layout `(app)` envolvia só `{children}` e o drawer (irmão do scrollport) renderizava hits `priority:alta` via `CampaignHoverTooltip` sem provider. Fix: provider envolve `CampaignAppScrollChrome`; pins unit focus→suggest e type→search. Plano: [`crash-busca-bottom-drawer.md`](docs/plans/crash-busca-bottom-drawer.md). -**Recently resolved (2026-08-01):** **Pool pin `fast=false`** — `resolvePoolModel` agora envia `fast=false` em todo spawn cuja família Cloud anuncia o param (`composer-2.5`, `grok-4.5`, …). Antes o supervisor pedia só `{ id: "composer-2.5" }`; a API Create-Agent resolve o default da variante (`fast=true`) e o usage dashboard mostrava `composer-2.5-fast` em todos os workers do pool. Skills `model-selection` / `agent-pool` + plano do orquestrador atualizados. - **Recently resolved (2026-08-01):** **C8 F1–F2** (Issue #37) — freshness audit confirmou entrega em código desde 2026-07-19: locks advisory em batch (`acquireTextAdvisoryLocks`), bulk import com `.returning()` + leituras drizzle, overview derivando `total` de `totalDocs`, migration `pg_trgm`, guard `contactSearchQuery` (mín. 2 chars). Plano [`escala-dry-pos-c6.md`](docs/plans/escala-dry-pos-c6.md) fechado; follow-ups de filtros/forms permanecem no C9. **Recently resolved (2026-08-01):** **Cutover residue cleanup** — removed dead `pnpm db:refresh:stage` / `scripts/refresh-stage.mjs` and `pnpm agent:promote` / `scripts/agent-promote.mjs`; closed `docs/CUTOVER-MAIN-ONLY.md`; scrubbed PR template `STAGE_DATABASE_URL` checklist item. Branch + Environment `stage` already deleted. **Recently resolved (2026-08-01):** **PRs de agente = Ready + auto-merge (audit incluso)** — removida a exceção do `engineering-audit` (artefatos também armam `gh pr merge --auto --merge`). Safety net GHA `agent-pr-ready-automerge.yml`: PRs `cursor/*` → `main` Draft→Ready + auto-merge. Rule always-on `agent-pr-workflow.mdc`. -**Recently resolved (2026-07-31):** **Cloud `ensure-postgres` falso negativo no start** — o script tratava “role/DBs `teqo` prontos” como “Postgres aceitando conexões”. Em snapshot warm o cluster já está `online` sem role/DBs; `start_postgres` falhava com `postgres is installed but not accepting connections` **antes** de `bootstrap_databases`, o `start` do `environment.json` saía ≠0 e a UI marcava `INSTALL_FAILED` mesmo com o agente utilizável. Fix: separar `server_ready` (`pg_isready` / peer) de `teqo_ready`, bootstrap após o server up, retry no wait; `cloud-setup.sh` pina `DATABASE_URL` e asserta `payload_migrations` pós-migrate (no-op silencioso visto no JIT). Ruído cosmético no mesmo log (dirs de recording, `.installed` de plugins ENOENT) é race do exec-daemon — não é do Teqo. - -**Recently resolved (2026-07-31):** **Pass 4 — auditoria de engenharia autônoma + guardrails das misses** — primeira execução em modo autônomo (Cursor Cloud) da skill engineering-audit, com precheck solitário (pool desligado) e primeiro harvest de `kind:agent-miss` (escopo OPS2: nasce `docs/GUARDRAILS.md`). **P0:** os 4 globals públicos (`site-settings`/`home`/`metadata`/`privacy-policy`) aceitavam qualquer autenticado no `update` — JWT de campanha (liderança inclusa) editava o site público via `/api/globals/*`; lockdown `payloadAdminOnly` + pins + guarda ([PR #91](https://github.com/fsolla/teqo/pull/91); editores perdem escrita nos globals de config deliberadamente). **P1:** `seed-posts.mjs` chamava `dieWithLabel` sem importar ([PR #92](https://github.com/fsolla/teqo/pull/92), com guarda de presença de import + `DAY_MS` single-source + ESLint lib→utilities sem type-import + guarda de `overrideAccess` omitido). **Misses:** #73 (deadlock `municipality_rels` — classe reproduzida: 7 violações int + 11 deep links e2e; migração para o alocador com teste de classe reescrito classe-agnóstico + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (Drawer por célula em 3 listas de chips → `CampaignListSheetProvider` + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (portão de readiness do autenticador virtual) + #54 (guarda de goto não assentado + convenção TESTING.md) em [PR #95](https://github.com/fsolla/teqo/pull/95); #83 fechada (guarda `agent:ship` já viva). **Registrado, não retry-fixed:** spec de chips de `campaignLeaderships` falha deterministicamente em dev na árvore limpa (nenhum POST dispara; prod 48/48 verde) — causa raiz via debug workflow (P4-L). Artefatos: seção Pass 4 no plano de qualidade, 15 linhas novas no ledger (2 stale fechadas: P3-A/P3-B), plano [`entrega-engenharia-p4.md`](docs/plans/entrega-engenharia-p4.md) com ondas P4-A→P4-L + mapa de guardas + defer+gatilho. - **Recently resolved (2026-07-31):** **CI build skip para diffs fora da árvore buildável** — `scripts/build-affected.mjs` + `classifyBuildScope` (núcleo em `test-affected-core.mjs`) decidem `run|none`; job `build-scope` barato no `ci-pr.yml` e o job `build` (Postgres + `pnpm build`) só roda em `mode=run`. Docs/tests/tooling/workflows pulam o build; `src/`, `public/` e configs do Next (`next.config`/`tsconfig`/`tailwind`/`postcss`/`components.json`/lockfile) forçam. `gate:ci` espelha. Push em `main` (`ci.yml`) continua sempre full. -**Recently resolved (2026-07-31):** **Consolidação de Issues (project-status)** — merge **B68+** (#39) → **O0+** (#35) (mesmo `model: kimi-k3-low`); **não** merge D3/D4/D5 (modelos mistos kimi vs composer); **não** merge B53–B55 (já `done`); fatia **B79** → chassis (#15) + **B91** busca no drawer (#76, `depends: [B79]`). Planos: `escala-dry-pos-onda0.md`, `busca-global-bottom-drawer.md`, `chassis-bottom-drawer-acoes-rapidas.md`. - ## Known Gaps (as of 2026-07-24 — resolve before relying on this file for onboarding new devs) Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.md`](docs/roadmap.md). @@ -190,10 +163,6 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-31):** **A6** Insight de dobradinhas 2026 (Issue #27) — card "Dobradinhas potenciais para 2026" na aba Eleições do detalhe do município: candidatos proporcionais com `runningAgain2026 = sim` ranqueados por score `0,6 alinhamento + 0,4 força local` (tiers compostos de `campoParties` 2026 + `electionPartySpectrum` — sem terceiro mapa; votos 2022 normalizados pelo máximo da geografia). Leitura derivada, sem migration/Consent; loader `municipalityTicketPartnerData` cacheado sob `election-tse` com a política dos irmãos; estado `pending2026` ("indisponível até as candidaturas de 2026") até a Fase 5 reconciliar pós-15/08 — o runbook da conciliação termina com `POST /api/revalidate?tag=election-tse`. Solla excluído (referência); link para `/campanha/dobradinhas` como caminho de registro (não cria registro paralelo). Int specs eleitorais agora se serializam pelo lease `election-collections` (`testDatabaseLease.ts`). Overview da lista ficou fora de escopo (strip de métricas + varredura estadual por request — follow-up possível pós-Fase 5). Plano: [`docs/plans/insight-dobradinha-2026.md`](docs/plans/insight-dobradinha-2026.md). -**Recently resolved (2026-07-30):** **Agent pool orchestrator** — supervisor remoto stateless que mantém até 5 Cursor Cloud Agents rodando `work-issue` sobre Issues `ready` autônomas: workflow `.github/workflows/agent-pool.yml` (tick `*/10min` + merge em `stage` + `workflow_dispatch` como canal canônico) rodando `scripts/agent-pool.mjs` (zero deps npm); elegibilidade fail-closed `isAutonomousClaimable` (`blocked`/`requirements-changed`/`needs:consent` nunca claimadas; `needs:migration` cede ao `migration-lock`); claim coordenado pelo supervisor (alocador único + lock otimista + `agentId` idempotente `bc-` — zero duplicata por construção); workers spawnados via Cloud Agents API v1 com o `model:` da Issue (fallback `composer-2.5`); estado escalar em repo variables `POOL_*`, ativos derivados (marcadores `pool-worker` + status dos runs); falha terminal → Issue a `blocked` + archive; fila drenada → auto-stop. `buildClaimQueue` extraído de `agent-claim` (comportamento idêntico, pinado). Nunca chama `agent:promote`. Requer repo secret `CURSOR_API_KEY`; `schedule` ativa após promote a `main`. Ops: skill `agent-pool`; plano: [`docs/plans/agent-pool-orchestrator.md`](docs/plans/agent-pool-orchestrator.md). - -**Recently resolved (2026-07-30):** **CI/dev workflow + desbloqueio PR #50** — o PR #50 (B53) ficou BLOCKED por duas causas de infra empilhadas no job `migration-lock` (sem `actions/checkout` → `gh` sem `.git`; depois `GraphQL: Resource not accessible by integration` → `GITHUB_TOKEN` sem `pull-requests: read`) e por um spec int frágil ao `db:seed:minimal` (`homeSearchLeaderships` assertava conjunto global com nome fixo `'Lider'`, que casa word-start com `Seed Liderança Um` do seed). O incidente gerou: (a) política **"Dono do PR, dono do CI"** (`docs/AGENT-OPS.md`) — done = PR mergeado em stage com CI verde, falha fora da feature também é do dono; (b) `ci-pr.yml` reescrito em **jobs paralelos**; (c) **testes afetados pelo diff** — `scripts/test-affected.mjs` (`vitest --changed` vs base do PR) e `scripts/e2e-affected.mjs` + manifesto `scripts/lib/e2e-affected-manifest.mjs`, fallback full em paths de risco (núcleo puro em `scripts/lib/test-affected-core.mjs`, pins unit); e2e entra bloqueante no PR; (d) `ci.yml` (main) virou rede de segurança completa (seed:minimal + build + e2e full); stage segue smoke; (e) fast gate DRY: scripts `gate:fast`/`gate:push`, `.husky/pre-push` roda `gate:push` (knip fora, escape `--no-verify`). - **Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos") — **renamed to `activity` / `/campanha/atividades` by C13 on 2026-07-25**; the old names survive only in frozen migration history. Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below). **Recently resolved (2026-07-18 evening / 2026-07-19):** **A4** Baseline no produto + Gap vs 2022 shipped and merged to `main` — `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` aggregate TSE 2022 by nucleus geography; `computeGapVs2022` + `NucleusInsights` on the overview tab; `NucleusElectoralBaseline` card (candidate / president / governor via `BASELINE_TICKET_2022`); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A `/simplify` pass applied targeted cleanup (role-generic ticket config, per-city `zoneNumber in […]`, shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE `cityCode`, Alert `confirmed` + `Progress` reuse) are registered as **A7** (`docs/plans/escala-dry-pos-a4.md`). @@ -210,8 +179,6 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-23):** **Export CSV de assinaturas e contatos** no admin Payload — `@payloadcms/plugin-import-export@3.82.0` habilitado em `signature` e `contact` (`import: false`, export CSV síncrono); flatten de `contact`/`petition` via `toCSV` em [`src/utilities/signatureExport.ts`](src/utilities/signatureExport.ts) (só assinaturas); `consent` excluído do export de assinaturas; collections `exports`/`imports` com access `isPayloadAdmin`; migration `20260723_025513_add_import_export_plugin`. Plano: [`docs/plans/exportar-csv-assinaturas.md`](docs/plans/exportar-csv-assinaturas.md). -**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.agents/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished. - **Recently resolved (2026-07-24):** **A11** Posição em votos do município — rank denso sobre as 435 unidades do catálogo + **% da própria votação** estadual (artefato `bahiaElectionAggregates`); helper `src/lib/municipalityVoteRank.ts`; UI no `MunicipalityBaselineCard` (por ano) e coluna/ordenação `?sort=votos` na lista (`sort`/`dir` mínimo `name`|`votos`; B15 amplia as demais keys). Plano: [`docs/plans/ranking-votos-municipio.md`](docs/plans/ranking-votos-municipio.md). **Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md). diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 40bbb785..53c76ec9 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -270,4 +270,3 @@ test.describe('Pessoas — lista unificada', () => { await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeHidden() }) }) -})