From c0ffc220261a957fe0716f3ac83a2272dfbf6bf4 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 15:22:12 -0300 Subject: [PATCH] =?UTF-8?q?feat(C118):=20p=C3=A1gina=20de=20detalhe=20da?= =?UTF-8?q?=20pessoa=20=E2=80=94=20se=C3=A7=C3=B5es=20montadas=20pelas=20c?= =?UTF-8?q?apacidades=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([]) + }) +})