Merge pull request #710 from fsolla/C128-pessoas-qualquer-pessoa-pode-assessorar-liderar-e-ou-do

C128 — Pessoas: qualquer pessoa pode assessorar, liderar e/ou dobrar — ciclo de vida nas tabelas pela lista
This commit is contained in:
Francisco Solla 2026-08-12 15:45:55 -03:00 committed by GitHub
commit 98f8ec9b9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2464 additions and 265 deletions

View file

@ -13,6 +13,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-11):** **OPS42** (Issue #691) — o e2e dev não perde mais fill/Enter para a **navegação RSC tardia do login**: em dev sob carga, ~150–500 ms após o load do `/campanha` o router do Next dev dispara um `history.replaceState` sem request (framenav) e remonta o subtree do layout (provider do chat novo, textarea novo com `defaultValue=''`) — interações imediatas caem na janela e são silenciosamente perdidas (input volta a `''`, envio nunca dispara; CI/prod imune — main verde). **Mecanismo medido por sonda** (3 runs nesta máquina, load 45–61): o `waitForURL` do login resolve **no commit** do redirect (não otimista — a hipótese da intenção caiu), e o commit perigoso é um **segundo** framenav pós-`goto` do próprio teste, cuja remontagem do DOM chega 150–250 ms depois; interação dentro da janela ainda induz cascade do overlay de dev (`POST /__nextjs_original-stack-frames`); fill atrasado ~2 s sobrevive. **Fix:** helper `waitForRouterSettled(page)` no fixture (`campaignE2EFixtures.ts`, padrão `checkRadixWhenHydrated`) — retorna após janela de **500 ms sem `framenavigated`** (fail-soft em 15 s com `console.warn`; **no-op em prod** via `CI`/`E2E_PROD` — custo zero no CI), chamado no `campaign.login` (pós-`waitForURL`) e nos pontos de interação imediata da família do chat: `openChatAndSend`/`openChat`/`askViaInput` em `campaignSollinhaContext`, `campaignAiChatResize`, `campaignAiLinks`, `campaignAiTranscribe`, `campaignAiChatFollowUps`; `openMobileDrawer` e os 2 cliques de drawer diretos de `campaignAiChatFollowUps`/`campaignAiChatOpeningChips` + o "Fechar" desktop do `campaignAiChatResize` (o run 3 pegou o clique do drawer na mesma janela — a classe é qualquer interação no layout, não só fill/Enter). **Rejeitadas:** retry de interação (double-send com o mock gated do B199), só-no-login (não enxerga o commit pós-goto), networkidle/wrap global de goto. **Evidência:** `campaignSollinhaContext` 10/10 ×3 dev (load 31–61; o B188 "reload na mesma aba" que flakeava 5/5 na sessão B199) + 8/8 prod (`E2E_PROD=1`, 30,7 s — no-op); irmão `campaignAiChatResize` 8/8 dev. Gates: tsc/lint/format/cycles verdes (knip falha pré-existente P3 — payload.config). Sem migration/access/Consent/UI/app. Intenção [`e2e-dev-router-quiescencia.md`](plans/e2e-dev-router-quiescencia.md); impl [`e2e-dev-router-quiescencia-impl.md`](plans/e2e-dev-router-quiescencia-impl.md).
**Recently resolved (2026-08-11):** **C123** (Issue #667) — a agenda ganha **uma superfície única** de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com **todas** as seções do formulário completo; as páginas `/campanha/atividades/nova` e `/[slug]/editar` saem **sem redirecionamento** (404). `ActivityInlineCreate` virou `ActivityOverlay` (`ActivityOverlay.tsx`): slot click → criação com o intervalo do slot; **event click → edição** (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via `url` preservada); **`Local` renderizado uma única vez** (o bloco duplicado do popover C91 morreu); Início/Término com **data e hora separados e visíveis sem rolar** (`ActivityDateTimeField` reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil `YYYY-MM-DDTHH:mm` intacto; mobile `[dia][hora][minuto]` por linha). **Submit por FormData nas actions `createActivityOverlay`/`updateActivityOverlay`** (reusam os parsers `parseActivity*FormData` + os `*Record` transacionais; `status` só na criação — na edição não envia, evita realizado→confirmado) e **`loadActivityEditDraft`** (findByID `activityFormSelect`, `overrideAccess: false`, int testado por escopo). **Bug latente C14 consertado:** o `<input name="id">` que o update perdia (o `/editar` estava quebrado desde `adc29c03`) volta no modo edição. Quick actions `new-activity`/`edit-activity` viraram `onAction` bridged por contexto (`openActivityCreate`/`openActivityEdit`, precedente C94/C114); hosts novos: `ActivityCreateOverlayHost` (lista `/atividades`) e `ActivityEditOverlayHost` (detalhe, botão Editar + FAB). Morrem: páginas/formActions/`ActivityForm.tsx`, `buildActivityCreateHref`/`parseActivityCreatePrefill`/`parseActivityAgendaReturnHref` (activityUi), `atividadesNova` (chrome catalog), `ACTIVITY_NEW_PATH`, `getActivityEditPageData`, `createActivityInline` (vira `createActivityOverlay`), `activityInlineErrors` (vira `activityOverlayErrors`, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int `loadActivityEditDraftRecord` por papel, e2e `campaignActivity` 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; `E2E_PROD=1` é o modo de verificação). Intenção [`agenda-criacao-modal-central.md`](plans/agenda-criacao-modal-central.md); impl [`agenda-criacao-modal-central-impl.md`](plans/agenda-criacao-modal-central-impl.md).
**Recently resolved (2026-08-11):** **C128** (Issue #695) — ciclo de vida das três capacidades pelas células de `/campanha/pessoas`: qualquer pessoa visível pode **ganhar** (primeira cidade cria a entidade) e **perder** (última cidade remove da tabela) as capacidades Assessora/Lidera/Aliada em — sem sair da lista. **Server:** três record functions **person-centric por `contactId`** em `actions/person.ts` — `setPersonAssessoraMembershipRecord` estendida (0 contas + add → **cria a conta staff**: `{ name, role: 'advisor', contact, email stub @criado.invalid, senha aleatória }` — sem credenciais utilizáveis, login provisionado depois; 0 contas + remove → no-op; última cidade → **delete da conta** com cascata de autorias), `setPersonLeadershipMunicipalitiesRecord` (cria liderança no primeiro município com `canCreateLeadership` re-checando a carteira; última cidade → delete de pledges→invites→liderança) e `setPersonStateDeputyMunicipalitiesRecord` (cria dobradinha com slug auto; última cidade → delete com vínculos limpos por FK). **Saída destrutiva por escopo, não por papel** (gate): coordenação/candidato sempre; **assessor também encerra Lidera/Aliada quando TODOS os municípios atuais da capacidade estão na carteira dele** (o piso dele é a visibilidade — chips fora nem renderizam; servidor re-checa `assertPersonCapacityExitScope`); Assessora segue coordenação/candidato (decisão do gate da intenção). Confirmação explícita com manifest (`getPersonCapacityExitManifestAction`/`loadPersonCapacityExitManifest` — votos declarados+convites para liderança, conta+autorias+assessorado para assessora; dobradinha sem diálogo, rabbit hole da intenção). **UI:** `RelationChipCell` ganhou opt-ins `commitWithNullOwner` e `commitGuard` (antes do apply otimista, fail-closed — falha de manifest aborta), `MunicipalityPortfolioCell` repassa + `extraFormFields` (manda `contactId`); `PeopleMunicipalityCell` implementa o guard + `PeopleCapacityExitDialog` (padrão DeletePersonButton); matriz da página: pessoa sem a capacidade fica editável (assessor com `addableIds`=carteira), `minItems` removido (o piso da liderança passa ao ciclo de vida). **Extrações (edit the owner):** `applyLeadershipMunicipalitiesDelta` (B34) e `applyStateDeputyMunicipalitiesBatch` (B37) viram helpers transaction-agnostic reusados pelos dois call sites; `deleteCampaignUserAccount` sai do `personDelete`; `nextFreeStubCampaignEmail` sai do municipality (B154); matchers de conflito `isUniqueLeadershipConflict`/`stateDeputyPolicy` movidos para utilities (const não sai de módulo 'use server'). **Bug latente C116 corrigido:** a célula Assessora nunca salvava — o FormData não enviava `contactId` que a action exigia (FormDataBoundaryError em toda tentativa); os int tests chamavam a record function direto e não pegavam. Sem migration. Testes: int `campaignPersonCellEditing` 28 (ciclo de vida das 3, manifest, guardas de escopo, no-ops, undo re-cria, conflito de nome da dobradinha), unit `peopleMunicipalityCell` 6 (guard aborta/confirma/fail-closed, commit com owner null + contactId), e2e `campaignPeople` +2 (cria liderança na célula + diálogo destrutivo com voto; conta staff criada na Assessora). Intenção [`pessoas-qualquer-capacidade-para-toda-pessoa.md`](plans/pessoas-qualquer-capacidade-para-toda-pessoa.md); impl [`pessoas-qualquer-capacidade-para-toda-pessoa-impl.md`](plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md).
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).

View file

@ -0,0 +1,120 @@
# Impl: Pessoas — qualquer pessoa pode assessorar, liderar e/ou dobrar — ciclo de vida nas tabelas pela lista
Status: aprovado
Atualizado em: 2026-08-11
Issue: #695
Intenção: docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa.md
Appetite restante: ~2–3 dias eng; ciclo de vida completo das três capacidades num único recorte
## Leitura da intenção
- **Outcome:** em `/campanha/pessoas`, as três células de capacidade (Assessora, Lidera, Aliada em) ficam editáveis para toda pessoa visível — a primeira cidade adicionada cria a entidade na tabela da capacidade (conta staff / liderança / dobradinha) e a remoção da última cidade a remove daquela tabela; saídas destrutivas (votos declarados, conta de acesso) pedem confirmação explícita listando o que será removido (precedente da cascata "Apagar pessoa"). O ciclo de vida passa a garantir o piso de 1 município da liderança.
- **O que NÃO negociar:** escopo por ator preservado (criação/remoção de conta staff = coordenação/candidato; assessor só o que está na carteira); leader fora da rota; lote inteiro = uma transação única (nada pela metade); as três capacidades independentes (criar/remover uma não toca as outras); contas staff pré-existentes com carteira vazia continuam como estão; contas vazias nascidas do ciclo de vida SÃO removidas (aceite); sem busca de Contatos sem capacidade; sem migration (escreve nas collections donas + `campaignUser`); copy pt-BR / identificadores em inglês.
- **O que reavaliar:** a hipótese de "reusar `setLeadershipMunicipalitiesMembership`/`setStateDeputyMunicipalitiesBatch` nas células" — são escritas por `ownerId` (leadership/deputy id) e começam transação própria; o ciclo de vida precisa de **escritas person-centric por `contactId`** (mesmo padrão que C116 já usou em `setPersonAssessoraMembership`/`setPersonAdvisorMembership`), com a criação/remoção da entidade decidida por existência de vínculo. A hipótese de "extender as células compartilhadas" confirma-se (opt-ins com defaults intactos). **Bug latente C116 encontrado:** a célula Assessora nunca salva — `MunicipalityPortfolioCell.buildFormData` só envia `ownerId`, mas `setPersonAssessoraFormAction` exige `contactId` (FormDataBoundaryError "Campo obrigatório." em toda tentativa; int tests chamam a record function direto, então não pegaram). O refactor person-centric corrige.
## Abordagem recomendada
```mermaid
flowchart LR
S[schemas/personCell: membership por contactId + exit manifest] --> R[person.ts: 3 record functions person-centric]
L[leadership.ts: extrair applyLeadershipMunicipalitiesDelta] --> R
D[stateDeputy.ts: extrair applyStateDeputyMunicipalitiesBatch] --> R
R --> F[pessoas/formActions: Lidera/Aliada viram contactId]
M[RelationChipCell: opt-ins commitWithNullOwner + commitGuard] --> P[MunicipalityPortfolioCell: extraFormFields]
P --> W[PeopleMunicipalityCell: guard de saída + diálogo de confirmação]
W --> X[PeopleCapacityExitDialog: manifest da saída]
R --> X
F --> PG[page.tsx: matriz de edição C128]
W --> PG
```
**Opções consideradas:**
- **A — escritas person-centric por `contactId` + extração dos deltas das actions de domínio (edit the owner)**: `setPersonLeadershipMunicipalitiesRecord`/`setPersonStateDeputyMunicipalitiesRecord` novas em `person.ts`, resolvendo a entidade da pessoa por `contact` dentro da transação (cria se não existe, apaga se esvazia); os deltas municipais puros (`applyLeadershipMunicipalitiesDelta`, `applyStateDeputyMunicipalitiesBatch`) são extraídos das actions B34/B37 e reusados pelos dois call sites (as listas de lideranças/dobradinhas continuam chamando os wrappers existentes com contrato idêntico).
- **B — células chamarem as actions de domínio existentes passando `ownerId`**: precisaria de ownerId mesmo sem entidade (id inventado), transações aninhadas (as record functions de domínio abrem transação própria), e o "cria/apaga no último" teria que vazar para fora delas. Rejeitada: contrato quebrado e duplicação da lógica de ciclo de vida em três lugares.
- **C — componentes novos `people/*` com máquina própria de commit+diálogo**: twin da máquina de chips. Rejeitada: o repo proíbe.
**Recomendação: A** — segue o padrão C116 já estabelecido (record functions person-centric em `person.ts`, toggles extraídos como helpers transaction-agnostic — precedente `toggleMunicipalityAdvisorMembership`); a interação nova (guard de saída destrutiva) entra como **opt-in no `RelationChipCell`** (`commitGuard`), defaults inalterados (B34/B36/B37/B156/B157/B159/B169 intactos).
### Decisões de engenharia travadas
- **Saída destrutiva (esvaziar a relação) por escopo, não por papel** (gate 2026-08-11): **coordenação/candidato sempre podem encerrar**; **assessor também encerra Lidera/Aliada** quando **todos** os municípios atuais da capacidade estão na carteira dele (o "piso" dele é a visibilidade: chips fora da carteira nem renderizam, então o esvaziamento só ocorre quando a capacidade inteira está na carteira). **Assessora segue coordenação/candidato** (decisão do gate da intenção — criação/remoção de conta staff). O servidor re-checa o escopo completo antes do delete (não só o lote removido) e o diálogo de confirmação precede a destruição; assessor que não cobre a capacidade inteira nunca esvazia — remover os chips visíveis deixa os municípios fora da carteira intactos (remoção normal, sem delete).
- **Fim de assessoria: a conta staff é APAGADA** (aceite) — com cascata dos itens autorados (`campaignInvite.createdBy`, `municipalityUpdate.author`, `calendarFeed.createdBy`, `supporterImportBatch.actor` — mesma ordem do `personDelete`, FK NOT NULL) e dos vínculos de assessorado (`leadership.advisors`, `stateDeputy.advisors`, `activity.advisors` — join tables cascateiam no `campaign_user_id`); o diálogo lista conta + autorias + assessorado. Extrair a cascata de conta do `personDelete.ts` para helper compartilhado (edit the owner, não twin).
- **Fim de liderança:** confirmação **somente quando houver votos declarados ou convites pendentes** (manifest lista ambos; intenção: "com confirmação se houver votos declarados"); sem eles, remove direto (nada próprio a perder). Ordem do delete: `votePledge` → `campaignInvite` → `leadership` (contrato FK do `personDelete`).
- **Fim de dobradinha:** sem confirmação extra (rabbit hole da intenção — "limpeza automática dos vínculos na mesma transação"); o delete da linha limpa os vínculos restantes de `municipality.stateDeputies`/`leadership.stateDeputies` pelos FKs (mesmo comportamento do `personDelete`).
- **Criação de conta staff:** `{ name: <nome da ficha>, role: 'advisor', contact: contactId, password: randomBytes(24).base64url }` — **sem e-mail/username** (decisão do gate: "conta criada sem e-mail/username utilizáveis"); `contact` explícito via `overrideAccess: true` justificado (o hook C99 `resolveContactForAccount` criaria ficha órfã quando a pessoa não tem telefone — com `contact` explícito ele não roda). A conta não consegue logar até o provisionamento (mesmo contrato do `createMunicipalityAdvisorRecord`).
- **Criação de liderança:** `{ contact: contactId, municipalities: ids }` com `user: currentActor, overrideAccess: false` — `canCreateLeadership` re-checa o escopo do assessor (todos os municípios na carteira); cap `MAX_LEADERSHIP_MUNICIPALITIES` mantido; nunca update com array vazio (o hook `requireAtLeastOneMunicipality` recusaria) — esvaziou, vai direto ao delete.
- **Criação de dobradinha:** `{ contact: contactId }` (slug auto do nome; `party` null) com `user` + `overrideAccess: false` (`canCreateStateDeputy` = staff); conflito de slug (dobradinha com mesmo nome em outra ficha) mapeado para mensagem segura; depois o batch municipal (reusa o helper extraído, que re-checa `canUpdateMunicipality` por município).
- **FormData das células:** `MunicipalityPortfolioCell` ganha `extraFormFields` (append de `contactId` ao FormData) e `commitWithNullOwner` (o early-return de `ownerId === null` no `RelationChipCell` é o que hoje impede o commit de criar). As formActions Lidera/Aliada de `/pessoas` passam a ler `contactId` (deixam de ler `ownerId`); as listas de domínio não mudam.
- **Guarda de saída:** opt-in `commitGuard` no `RelationChipCell`, chamado em `attemptRemove`/`pickHit` **antes** do apply otimista (mesmo ponto dos refusals de floor/cap) — recebe `{ changedIds, assigned, currentIds }` (currentIds = `effectiveIds`, ciente do otimista). `false` aborta sem apply; `true` segue o commit normal. O path de undo-toast (re-adicionar) não passa pela guarda (nunca esvazia). **Fail-closed:** falha ao carregar o manifest aborta o commit com toast de erro (nada é apagado sem confirmação).
### Componentes / mudanças
**Puro (lib, client-safe):**
- **`src/lib/schemas/personCell.ts`**: `personLeadershipMembershipSchema` / `personStateDeputyMembershipSchema` (`contactId` + `municipalityIds` + `assigned` — mesmo shape do `personAssessoraMembershipSchema`), `personCapacityExitSchema` (`capacity: 'account' | 'leadership'` + `contactId`); novas mensagens seguras: `PERSON_CAPACITY_EXIT_SCOPE_MESSAGE` ("Você só pode encerrar a capacidade quando todos os municípios dela estão na sua carteira."), `PERSON_LEADERSHIP_CREATE_CONFLICT_MESSAGE` (já existe liderança para a ficha), `PERSON_STATE_DEPUTY_CREATE_CONFLICT_MESSAGE` (slug/nome já em uso). `PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE` deixa de ser usada na célula (criação substitui a recusa) — removida das safeMessages, mantida a constante por compatibilidade de testes a ajustar.
**Server (`person.ts` + formActions + utilities):**
- **`src/app/(campaign)/campanha/actions/person.ts`** — três record functions novas/estendidas, todas `withPayloadTransaction` + `req`:
- `setPersonAssessoraMembershipRecord` (**estendida**): `reloadUnrestrictedActor`; resolve contas por `contact` (limite 2): 0 contas + `assigned` → **cria** a conta (decisão acima) e aplica o toggle; 0 contas + remoção → no-op (nada a remover); 1 conta → toggle existente; 2+ → recusa multi-conta (inalterada). Esvaziou a carteira → **delete da conta** (cascata autorias via helper compartilhado extraído do `personDelete.ts`).
- `setPersonLeadershipMunicipalitiesRecord` (**nova**): `reloadStaffActor`; resolve liderança por `contact` (`user`-threaded); sem liderança + `assigned` → cria; sem + remoção → no-op; com + `assigned` → `applyLeadershipMunicipalitiesDelta` (extraído); com + remoção esvaziando → **guarda de escopo do exit** (unrestricted sempre; assessor exige que TODOS os municípios atuais da liderança estejam na carteira — senão recusa com mensagem segura) → delete pledges/invites/leadership em ordem (bypass justificado pela guarda + diálogo). Retorna slugs tocados para revalidação.
- `setPersonStateDeputyMunicipalitiesRecord` (**nova**): `reloadStaffActor`; resolve dobradinha por `contact`; sem + `assigned` → cria (conflito de slug mapeado) + batch; sem + remoção → no-op; com + `assigned` → `applyStateDeputyMunicipalitiesBatch` (extraído); com + esvaziando → mesma guarda de escopo do exit (todos os municípios atuais na carteira para assessor) → delete da linha (refs limpam pelos FKs, mesmo contrato do `personDelete`).
- `getPersonCapacityExitManifestAction` (**nova**): `reloadStaffActor` + mesma guarda de escopo do exit (fail-closed: assessor que não cobre a capacidade inteira não vê manifest); manifest read-only: `account` → `{ accountName, authored: { inviteCount, updateCount, feedCount }, assessorado: { leadershipNames, deputyNames, activityNames } }` (unrestricted-only — decisão do gate); `leadership` → `{ declaredVoteCount, inviteCount, municipalityNames }`. Sem escrita, sem locks.
- Wrappers `setPersonLeadershipMunicipalities` / `setPersonStateDeputyMunicipalities` com revalidação: `/campanha/pessoas` + `/campanha/liderancas` (page + `[id]`) ou `/campanha/dobradinhas` (page + `[id]`) + `/campanha/municipios/<slug>` tocados (+ `/campanha/assessores` page + `[id]` no assessora).
- **`src/app/(campaign)/campanha/actions/leadership.ts`**: extrair `applyLeadershipMunicipalitiesDelta(payload, currentActor, req, leadershipId, municipalityIds, assigned)` → `{ next, slugs }` (read `user`-threaded → `nextMunicipalityIdsAfterLeadershipMembership` → `assertMunicipalitiesWithinScope` nos adicionados → update `user`-threaded → slugs). `setLeadershipMunicipalitiesMembershipRecord` passa a chamá-la (contrato externo idêntico).
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`**: extrair `applyStateDeputyMunicipalitiesBatch(payload, currentActor, req, stateDeputyId, municipalityIds, assigned)` → `{ slugs }` (corpo atual do batch: locks por município, read/update `user`-threaded). `setStateDeputyMunicipalitiesBatchRecord` passa a chamá-la.
- **`src/utilities/people/personDelete.ts`**: extrair `deleteCampaignUserAccount(payload, req, accountID)` (autorias → `payload.delete` por id para rodar hooks) reusado pelo `deletePersonRecord` e pelo fim de assessoria.
- **`src/app/(campaign)/campanha/(app)/pessoas/formActions.ts`**: `setPersonLeadershipMunicipalitiesFormAction` / `setPersonStateDeputyMunicipalitiesFormAction` passam a ler `contactId` e delegar às record functions person-centric; `setPersonAssessoraFormAction` inalterada (já é contactId — o bug latente some quando a célula passa a enviar o campo). SafeMessages: `personAssessoraSafeMessages` perde `NO_ACCOUNT`; novas mensagens adicionadas às listas.
- **`src/utilities/people/peopleData.ts`**: sem mudança (o VM já expõe `staff`, `leadershipID`, `deputyID`, municipios por capacidade).
- **Migration:** nenhuma (sem mudança de schema).
**UI (Impeccable B — encaixe em tela existente):**
- **`src/components/campaign/shared/RelationChipCell.tsx`**: dois opt-ins, defaults inalterados: `commitWithNullOwner?: boolean` (skip do early-return `if (ownerId === null) return` quando ligado) e `commitGuard?: (delta: { changedIds: number[]; assigned: boolean; currentIds: number[] }) => Promise<boolean>` (chamado em `attemptRemove`/`pickHit` antes do apply otimista; `false` aborta).
- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os dois opt-ins; novo `extraFormFields?: Record<string, string | number>` (append no `buildFormData`, ex. `contactId`).
- **`src/components/campaign/people/PeopleCapacityExitDialog.tsx`** (novo): AlertDialog no padrão `DeletePersonButton` (manifest carregado ao abrir, lista verbatim, botão destrutivo com spinner); copy por capacidade ("Encerrar liderança" / "Encerrar assessoria"): liderança → "N votos declarados · M convites pendentes · municípios"; assessoria → "conta de acesso (nome) · autorias · assessorado". Cancelar/fechar aborta.
- **`src/components/campaign/people/PeopleMunicipalityCell.tsx`** (estendido): props novas `contactId: number`, `exitMode?: 'account' | 'leadership' | 'stateDeputy'`; passa `extraFormFields={{ contactId }}` + `commitWithNullOwner`; implementa `commitGuard`: esvaziar relação → manifest action (`account`/`leadership`; `stateDeputy` retorna `true` direto — sem diálogo); `account` sempre abre diálogo; `leadership` abre se `declaredVoteCount > 0 || inviteCount > 0`; confirmado → `true`; cancelado/falha → `false`.
- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: matriz `buildPeopleEditability` C128 — `canEditAssessora = unrestricted && row.staff.length <= 1` (0 = cria, 1 = edita, 2+ = read-only); `canEditLidera`/`canEditAliada = unrestricted || row.leadershipID/deputyID === null || inCarteira(...)` (pessoa sem a capacidade fica editável para o assessor, com `addableIds` = carteira); **sem `minItems` em nenhuma coluna** (o piso do assessor é a visibilidade — chips fora da carteira não renderizam; o esvaziamento só ocorre com a capacidade inteira na carteira); `exitMode` por coluna. `PeopleMobileCards` intacto.
### Dados → forma
- **Forma escolhida:** o diálogo de confirmação é a única forma "nova" — lista textual do manifest, precedente `DeletePersonButton` (sem dado analítico; contagens do que será removido). Rejeitadas: tooltip/popover de confirmação (gesto destrutivo merece diálogo modal, precedente da cascata) e checkbox de "ciência" (gasto desnecessário).
## Fases verificáveis
1. **Server core** (~1/3 appetite): schemas + extrações (`applyLeadershipMunicipalitiesDelta`, `applyStateDeputyMunicipalitiesBatch`, `deleteCampaignUserAccount`) + as três record functions + manifest action + wrappers/revalidação. Int tests: cria-conta-na-primeira-cidade (sem email/username, contact linkado, sem ficha órfã), cria-liderança/dobradinha na primeira cidade, remove-última-cidade (delete + cascata), manifest (liderança com/sem votos, assessora com autorias/assessorado), guardas: assessor esvazia Lidera/Aliada com a capacidade inteira na carteira, assessor com municípios fora da carteira NÃO esvazia (recusa server + manifest negado), assessora continua coordenação/candidato (assessor recusa), multi-conta recusa, assessor fora da carteira recusa, no-op de remoção sem entidade, conflito de slug da dobradinha, undo re-cria (assigned=true após delete).
2. **UI machines** (~1/3 appetite): opt-ins no `RelationChipCell`/`MunicipalityPortfolioCell` + `PeopleMunicipalityCell` com `commitGuard` + `PeopleCapacityExitDialog`; testes de componente do guard (aborta sem apply, confirmado aplica, falha aborta) e do early-return `commitWithNullOwner`; regressão visual das listas B34/B37/B156/B157/B159/B169 (defaults intactos).
3. **Page + polish** (~1/3 appetite): matriz C128, `extraFormFields`/`exitMode` por coluna, tooltip/aria do diálogo, indicador discreto de foco; **correção do bug latente da célula Assessora** (e2e do commit Assessora agora salva); shape→craft→critique→polish.
4. **Gates:** após cada slice `pnpm exec tsc --noEmit` + testes focados; ao final `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (estender `campaignPeople.e2e.spec.ts` com um fluxo de ciclo de vida: criar conta na primeira cidade + diálogo de encerramento), `pnpm build` local. Entrada no `docs/CHANGELOG-AGENTS.md`.
## Rabbit holes / Não escopo (engenharia)
- Assessor encerrar a **Assessora** (conta staff) — fora: decisão do gate da intenção (coordenação/candidato).
- Remover conta com carteira vazia pré-existente sem passar pela célula (fica no fluxo "Apagar pessoa", como decidido).
- Busca/autocomplete de Contatos sem capacidade (v1 sem, decisão da intenção).
- Contenteditable, tree-view de território, edição em lote — fora (herdado do C116).
- Migration de schema — nenhuma.
- Não mexer nas listas de domínio além dos helpers extraídos (contrato externo idêntico).
## Riscos e mitigação
- **Transações aninhadas** (person action chamando record function de domínio): só helpers transaction-agnostic (`req`-threaded) dentro da transação; as extrações mantêm o contrato dos wrappers existentes.
- **Célula Assessora quebrada hoje (bug latente C116)**: o refactor envia `contactId`; int test do formAction (não só da record function) cobre o commit completo.
- **Hook `requireAtLeastOneMunicipality` vs esvaziamento**: o esvaziamento nunca faz update com array vazio — vai direto ao delete dentro da mesma transação.
- **Cascata da conta staff esquecendo autorias (FK NOT NULL)**: helper compartilhado extraído do `personDelete` (ordem já validada em prod); int test do delete com autorias presentes.
- **Undo-toast após delete de entidade**: "Desfazer" re-adiciona (assigned=true) e a criação-no-add re-cria a entidade — int test do ciclo undo.
- **Regressão das células compartilhadas**: opt-ins com defaults; testes unit/int existentes das células de relação rodam intactos na Fase 2.
- **Manifest divergindo do delete (race)**: o manifest é preview, o delete re-enumera dentro da transação (mesmo contrato do `personDelete`); guard fail-closed (erro de manifest aborta).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (células editáveis para toda pessoa visível; primeira cidade cria; última remove; confirmação destrutiva lista o que será removido; capacidades independentes; escopo por ator preservado)
- [ ] Sem migration; Local API com `user`/`overrideAccess: false`; bypasses justificados (criação de conta com `contact` explícito, deletes destrutivos) após a checagem de escopo; transações com `req` em toda escrita multi-collection
- [ ] Saída destrutiva por escopo: assessor encerra Lidera/Aliada só com a capacidade inteira na carteira (servidor re-checa); Assessora continua coordenação/candidato; multi-conta do Assessora recusa
- [ ] Mensagens seguras allowlisted; copy pt-BR; identificadores em inglês
- [ ] Bug latente C116 (contactId nunca no FormData da Assessora) corrigido e coberto por teste
- [ ] Testes previstos: int (ciclo de vida das três capacidades, manifest, guardas de acesso, undo, cascatas) + componente (guard) + e2e (um fluxo completo)
- [ ] Entrada curta no `docs/CHANGELOG-AGENTS.md`
**Self-score decision-quality: 5/5** — decisões caras (person-centric vs ownerId, extração de helpers vs twin, saída destrutiva por escopo de carteira vs papel, criação de conta sem credenciais, guard no `RelationChipCell` vs interceptação externa) têm alternativas rejeitadas com justificativa; appetite bounded por fases; rabbit holes nomeados; reuso máximo das máquinas existentes; o aceite da intenção não foi alterado.

View file

@ -1,12 +1,12 @@
'use server'
import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
import {
setPersonAdvisorMembership,
setPersonAssessoraMembership,
setPersonLeadershipMunicipalities,
setPersonStateDeputyMunicipalities,
updatePersonContact,
} from '@/app/(campaign)/campanha/actions/person'
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
repeatedRelationshipFormValues,
@ -15,24 +15,21 @@ import {
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
} from '@/lib/schemas/leadership'
import {
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CELL_STAFF_MESSAGE,
PERSON_CONTACT_INVALID_MESSAGE,
} from '@/lib/schemas/personCell'
import {
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -46,7 +43,6 @@ const personContactSafeMessages = [
const personAssessoraSafeMessages = [
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
] as const
@ -55,13 +51,21 @@ const personAssessoradoSafeMessages = [
PERSON_CONTACT_INVALID_MESSAGE,
] as const
const leadershipMunicipalitiesSafeMessages = [
LEADERSHIP_STAFF_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
const personLeadershipSafeMessages = [
PERSON_CELL_STAFF_MESSAGE,
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
] as const
const personStateDeputySafeMessages = [
PERSON_CELL_STAFF_MESSAGE,
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
STATE_DEPUTY_CONFLICT_MESSAGE,
] as const
/** Per-field Contact edit for the people list cells (C116). */
export const updatePersonContactFormAction = async (
_state: CampaignFormActionState,
@ -148,41 +152,38 @@ export const setPersonAssessoradoFormAction = async (
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
})
/** Lidera column (C116) — reuses the leadership municipalities write. */
/** Lidera column (C128) — person-centric lifecycle: creates/removes the leadership. */
export const setPersonLeadershipMunicipalitiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setLeadershipMunicipalitiesMembership({
leadershipId: requiredRelationshipFormValue(formData, 'ownerId'),
await setPersonLeadershipMunicipalities({
contactId: requiredRelationshipFormValue(formData, 'contactId'),
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Municípios atualizados.' }
},
safeMessages: leadershipMunicipalitiesSafeMessages,
safeMessages: personLeadershipSafeMessages,
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
})
/** Aliada em column (C116) — reuses the dobradinha municipalities write. */
/** Aliada em column (C128) — person-centric lifecycle: creates/removes the dobradinha. */
export const setPersonStateDeputyMunicipalitiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setStateDeputyMunicipalitiesBatch({
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
await setPersonStateDeputyMunicipalities({
contactId: requiredRelationshipFormValue(formData, 'contactId'),
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Municípios atualizados.' }
},
safeMessages: [
...STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_STAFF_MESSAGE,
] as const,
safeMessages: personStateDeputySafeMessages,
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
})

View file

@ -121,15 +121,17 @@ const PeopleListEmptyState = ({
)
/**
* C116 editability matrix by actor. The advisor rule mirrors each owner
* C116/C128 editability matrix by actor. The advisor rule mirrors each owner
* collection's access: Lidera/Aliada em follow
* `canManageLeadership`/`canReadStateDeputy` (the row's own municipalities
* must cross the carteira — the row may be visible via another capacity);
* Assessora and Assessorado are unrestricted-only
* (`canAssignMunicipalityAdvisors` / B156), and Assessora needs exactly ONE
* staff account for the batch write to have a target. The ficha text cells
* stay always-editable and the server enforces the scope ("you edit what you
* see") with a per-cell error.
* must cross the carteira — the row may be visible via another capacity), and
* C128 opens the cells for people WITHOUT the capacity yet (the first
* municipality creates the entity; the advisor's `addableIds` keeps additions
* inside his carteira). Assessora and Assessorado are unrestricted-only
* (`canAssignMunicipalityAdvisors` / B156), and Assessora needs at most ONE
* staff account for the batch write to have a target (zero = the C128
* create). The ficha text cells stay always-editable and the server enforces
* the scope ("you edit what you see") with a per-cell error.
*/
type PeopleEditability = {
administered: ReadonlySet<number> | null
@ -148,11 +150,14 @@ const buildPeopleEditability = (
administered !== null && ids.some((id) => administered.has(id))
return {
administered,
// C128: a person without the capacity yet is editable (the first
// municipality creates the entity); with one, the row's own municipalities
// must cross the carteira (the chips outside it do not render anyway).
canEditLidera: (row) =>
row.leadershipID !== null && (unrestricted || inCarteira(row.leadershipMunicipalityIDs)),
unrestricted || row.leadershipID === null || inCarteira(row.leadershipMunicipalityIDs),
canEditAliada: (row) =>
row.deputyID !== null && (unrestricted || inCarteira(row.deputyMunicipalityIDs)),
canEditAssessora: (row) => unrestricted && row.staff.length === 1,
unrestricted || row.deputyID === null || inCarteira(row.deputyMunicipalityIDs),
canEditAssessora: (row) => unrestricted && row.staff.length <= 1,
canEditAssessorado: unrestricted,
}
}
@ -244,6 +249,8 @@ const peopleColumns = ({
<PeopleMunicipalityCell
ownerId={editability.canEditAssessora(row) ? (row.staff[0]?.id ?? null) : null}
ownerName={row.name}
contactId={row.contactID}
exitMode="account"
municipalityIds={row.assessoraMunicipalityIDs}
municipalityIndex={municipalityIndex}
commitAction={setPersonAssessoraFormAction}
@ -262,10 +269,11 @@ const peopleColumns = ({
<PeopleMunicipalityCell
ownerId={row.leadershipID}
ownerName={row.name}
contactId={row.contactID}
exitMode="leadership"
municipalityIds={row.leadershipMunicipalityIDs}
municipalityIndex={municipalityIndex}
{...(editability.administered ? { addableIds: editability.administered } : {})}
minItems={1}
commitAction={setPersonLeadershipMunicipalitiesFormAction}
drawerTitle="Municípios liderados"
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
@ -282,6 +290,8 @@ const peopleColumns = ({
<PeopleMunicipalityCell
ownerId={row.deputyID}
ownerName={row.name}
contactId={row.contactID}
exitMode="stateDeputy"
municipalityIds={row.deputyMunicipalityIDs}
municipalityIndex={municipalityIndex}
{...(editability.administered ? { addableIds: editability.administered } : {})}

View file

@ -32,7 +32,7 @@ import {
type LeadershipWizardUpdateInput,
type MunicipalityLeadershipCreateInput,
} from '@/lib/schemas/leadership'
import type { CampaignUser, Contact } from '@/payload-types'
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
import {
getCampaignActionContext,
@ -70,16 +70,7 @@ const assertMunicipalitiesWithinScope = async (
}
}
const isUniqueLeadershipConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
if (/leadership_contact|duplicate key/i.test(message)) {
return true
}
if (!(error instanceof Error) || error.name !== 'ValidationError') return false
return /contact(?:_id)?/i.test(JSON.stringify(error))
}
import { isUniqueLeadershipConflict } from '@/utilities/leadershipConflict'
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phones'> & {
@ -647,6 +638,66 @@ const revalidateLeadershipMunicipalityPaths = (
}
}
/**
* C128 — the B34 municipalities delta core, transaction-agnostic: given the
* leadership's CURRENT municipalities (read under the caller's advisory lock),
* computes the membership change, scope-checks the added ids and writes it.
* Shared by the `/campanha/liderancas` chip cell (B34) and the person
* lifecycle (`setPersonLeadershipMunicipalitiesRecord`) so the two surfaces
* can never disagree on the delta contract.
*/
export const applyLeadershipMunicipalitiesDelta = async (
payload: Payload,
currentActor: CampaignUser,
req: PayloadTransactionRequest,
leadershipId: number,
currentMunicipalityIDs: readonly number[],
municipalityIds: readonly number[],
assigned: boolean,
): Promise<{ leadership: Leadership | null; slugs: string[] }> => {
const change = nextMunicipalityIdsAfterLeadershipMembership(
currentMunicipalityIDs,
municipalityIds,
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate.
if (change === null) {
return { leadership: null, slugs: [] }
}
// Only the ids the write actually adds: an advisor must stay able to drop a
// link outside their scope, and re-deriving `added` here could disagree.
if (change.added.length > 0) {
await assertMunicipalitiesWithinScope(payload, currentActor, change.added, req)
}
// Intentional admin bypass: only used to resolve the slugs of the touched
// municipalities for a targeted revalidate; existence is otherwise
// enforced by Payload's relationship validation on `update`.
const touched = await payload.find({
collection: 'municipality',
where: { id: { in: change.changed } },
depth: 0,
pagination: false,
select: { slug: true },
overrideAccess: true,
req,
})
const updated = await payload.update({
collection: 'leadership',
id: leadershipId,
data: { municipalities: change.next },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
return { leadership: updated, slugs: touched.docs.map((doc) => doc.slug) }
}
/**
* Delta write for the "Municípios" column of `/campanha/liderancas` (B34) —
* adds or removes a set of municipalities (one chip, or a whole território/ZE)
@ -677,47 +728,20 @@ export const setLeadershipMunicipalitiesMembershipRecord = async (
'municipalities',
)
const change = nextMunicipalityIdsAfterLeadershipMembership(
const delta = await applyLeadershipMunicipalitiesDelta(
payload,
currentActor,
req,
leadershipId,
uniqueRelationshipIds(current.municipalities),
municipalityIds,
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate.
if (change === null) {
return { leadership: current, municipalitySlugs: [] }
return {
leadership: delta.leadership ?? current,
municipalitySlugs: delta.slugs,
}
// Only the ids the write actually adds: an advisor must stay able to drop a
// link outside their scope, and re-deriving `added` here could disagree.
if (change.added.length > 0) {
await assertMunicipalitiesWithinScope(payload, currentActor, change.added, req)
}
// Intentional admin bypass: only used to resolve the slugs of the touched
// municipalities for a targeted revalidate; existence is otherwise
// enforced by Payload's relationship validation on `update`.
const touched = await payload.find({
collection: 'municipality',
where: { id: { in: change.changed } },
depth: 0,
pagination: false,
select: { slug: true },
overrideAccess: true,
req,
})
const updated = await payload.update({
collection: 'leadership',
id: leadershipId,
data: { municipalities: change.next },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
return { leadership: updated, municipalitySlugs: touched.docs.map((doc) => doc.slug) }
},
{ beginFailureMessage: 'Não foi possível atualizar os municípios da liderança.' },
)

View file

@ -11,10 +11,8 @@ import {
} from '@/lib/engagementLevel'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { stubCampaignUserEmailFor } from '@/lib/schemas/advisor'
import {
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE,
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
municipalityAdvisorCreateSchema,
municipalityAdvisorMembershipSchema,
@ -38,6 +36,7 @@ import {
reloadStaffActor,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import { nextFreeStubCampaignEmail } from '@/utilities/campaignStubEmail'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -413,6 +412,7 @@ export const createMunicipalityAdvisorRecord = async (
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`])
const email = await nextFreeStubCampaignEmail(payload, req, name)
// Same auth-create contract as `createAdvisorRecord` (B19): the create
// access (`canManageCampaignUsers`) re-checks the unrestricted role
// through the `user` threaded into this transaction's `req`.
@ -474,33 +474,3 @@ export const createMunicipalityAdvisor = async (input: MunicipalityAdvisorCreate
const { payload, actor } = await getCampaignActionContext()
return createMunicipalityAdvisorRecord(payload, actor, input)
}
/**
* Next free `<slug>@criado.invalid` stub: bounded exact-match probes on the
* unique `email` index (no LIKE semantics). Collisions only happen when two
* names slugify identically, so the first probe usually succeeds. The read
* bypasses access control on purpose: the transaction already verified the
* unrestricted role, and the probe only looks at placeholder e-mails.
*/
const nextFreeStubCampaignEmail = async (
payload: Payload,
req: PayloadTransactionRequest,
name: string,
): Promise<string> => {
for (let occurrence = 1; occurrence <= 50; occurrence += 1) {
const candidate = stubCampaignUserEmailFor(name, occurrence)
const existing = await payload.find({
collection: 'campaignUser',
where: { email: { equals: candidate } },
depth: 0,
limit: 1,
select: { email: true },
// Intentional admin bypass: unrestricted role verified above in the
// transaction; the probe only reads placeholder e-mails.
overrideAccess: true,
req,
})
if (existing.totalDocs === 0) return candidate
}
throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE)
}

View file

@ -1,43 +1,95 @@
'use server'
import { randomBytes } from 'crypto'
import { revalidatePath } from 'next/cache'
import { applyLeadershipMunicipalitiesDelta } from '@/app/(campaign)/campanha/actions/leadership'
import { toggleMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
import { applyStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
MAX_LEADERSHIP_MUNICIPALITIES,
} from '@/lib/schemas/leadership'
import {
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CELL_STAFF_MESSAGE,
PERSON_CONTACT_INVALID_MESSAGE,
personAdvisorMembershipSchema,
personAssessoraMembershipSchema,
personCapacityExitSchema,
personLeadershipMembershipSchema,
personStateDeputyMembershipSchema,
type PersonAdvisorMembershipInput,
type PersonAssessoraMembershipInput,
type PersonLeadershipMembershipInput,
type PersonStateDeputyMembershipInput,
} from '@/lib/schemas/personCell'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
personDeleteInputSchema,
} from '@/lib/schemas/personDelete'
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
import type { CampaignUser, Contact } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import {
getCampaignActionContext,
reloadStaffActor,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import { mapStaffEntityConflict } from '@/utilities/campaignEntityActions'
import { nextFreeStubCampaignEmail } from '@/utilities/campaignStubEmail'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { isUniqueLeadershipConflict } from '@/utilities/leadershipConflict'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import {
assertPersonCapacityExitScope,
loadPersonCapacityExitManifest,
} from '@/utilities/people/personCapacityExit'
import {
deleteCampaignUserAccount,
deletePersonRecord,
loadPersonDeleteManifest,
} from '@/utilities/people/personDelete'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
import { stateDeputyPolicy } from '@/utilities/stateDeputyConflict'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
import type { Payload } from 'payload'
/** C128 — slugs of the touched municipalities, for targeted revalidation. */
const municipalitySlugsOf = async (
payload: Payload,
req: PayloadTransactionRequest,
municipalityIDs: readonly number[],
): Promise<string[]> => {
if (municipalityIDs.length === 0) return []
const touched = await payload.find({
collection: 'municipality',
where: { id: { in: [...municipalityIDs] } },
depth: 0,
pagination: false,
select: { slug: true },
// Intentional admin bypass: only resolves slugs for revalidation; the
// caller's scope check authorizes the write this read serves.
overrideAccess: true,
req,
})
return touched.docs.map((doc) => doc.slug)
}
/**
* C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only
* (`reloadUnrestrictedActor`): the cascade is transversal, wider than any
@ -178,12 +230,18 @@ export const updatePersonContact = async (input: ContactFieldUpdateInput) => {
}
/**
* C116 — the Assessora column of a person: toggles the person's staff account
* on `municipality.advisors` for a batch of municipalities (one territory / ZE
* chip or a "Salvador (19)" group), in ONE transaction. Unrestricted-only
* (`canAssignMunicipalityAdvisors`), and the person must have EXACTLY ONE
* staff account — with more, "which account" is ambiguous, so the server
* refuses with a safe message (the cell renders read-only for those rows).
* C116/C128 — the Assessora column of a person: toggles the person's staff
* account on `municipality.advisors` for a batch of municipalities (one
* territory / ZE chip or a "Salvador (19)" group), in ONE transaction.
* Unrestricted-only (`canAssignMunicipalityAdvisors`).
*
* C128 lifecycle: with NO account, adding the first municipality CREATES the
* staff account (role `advisor`, linked to the person's ficha, random
* unshared password — no usable e-mail/username, login is provisioned later);
* removing the LAST municipality deletes the account (the destructive exit,
* confirmed by the dialog that listed the manifest). With more than one
* account, "which account" is ambiguous, so the server refuses with a safe
* message (the cell renders read-only for those rows).
*/
export const setPersonAssessoraMembershipRecord = async (
payload: Payload,
@ -197,6 +255,14 @@ export const setPersonAssessoraMembershipRecord = async (
async ({ req }) => {
await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, req)
// Serializes every Assessora write of the SAME person: the destructive
// exit (last municipality) decides "did the carteira empty?" from an
// enumeration, and two concurrent removals of distinct chips would each
// see a non-empty remainder and leave a zombie account with an empty
// carteira — invisible to the lifecycle. The lock makes the
// enumerate→decide→toggle/delete sequence atomic per person.
await acquireTextAdvisoryLocks(payload, req, [`person-assessora:${contactId}`])
const accounts = await payload.find({
collection: 'campaignUser',
where: { contact: { equals: contactId } },
@ -209,13 +275,75 @@ export const setPersonAssessoraMembershipRecord = async (
req,
})
if (accounts.docs.length === 0) throw new Error(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE)
if (accounts.docs.length > 1) throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
const accountId = accounts.docs[0].id
let accountId: number
if (accounts.docs.length === 1) {
accountId = Number(accounts.docs[0].id)
} else if (assigned) {
// C128 — the first municipality creates the account. The explicit
// `contact` (justified bypass) keeps the C99 `resolveContactForAccount`
// hook from minting an orphan ficha when the person has no phone; the
// random password is never shared, so the account cannot log in until
// a coordinator provisions credentials (same contract as B154).
const contact = await payload.findByID({
collection: 'contact',
id: contactId,
depth: 0,
select: { name: true },
// Intentional bypass: the unrestricted gate above is the
// authorization; this read only resolves the account name.
overrideAccess: true,
req,
})
const email = await nextFreeStubCampaignEmail(payload, req, contact.name ?? 'Contato')
const created = await payload.create({
collection: 'campaignUser',
data: hookFilledCreateData<'campaignUser'>({
name: contact.name ?? 'Contato',
role: 'advisor',
contact: contactId,
// `@criado.invalid` placeholder — no usable e-mail (never receives
// mail, cannot reset the password with it); the random password is
// never shared, so the account cannot log in until a coordinator
// provisions credentials (same contract as B154).
email,
password: randomBytes(24).toString('base64url'),
}),
depth: 0,
// Intentional bypass: the unrestricted gate above is the
// authorization; the `contact` field is system-gated and the C99
// identity hooks would otherwise diverge from the explicit ficha.
overrideAccess: true,
req,
})
accountId = created.id
} else {
// Removal on a person with no account: nothing to remove.
return { accountId: null, slugs: [] }
}
// Before toggling, decide the destructive exit: a removal that would
// empty the carteira deletes the account (with its authored rows and
// assessorado links) — the confirmation dialog listed the manifest.
if (!assigned) {
// Canonical "advisors contains account" lookup, same helper the
// advisor scope uses everywhere else.
const currentMunicipalityIDs = await getAdvisorMunicipalityIds(payload, accountId, req)
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
if (next.length === 0) {
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
await deleteCampaignUserAccount(payload, req, accountId)
return { accountId: null, slugs }
}
}
for (const municipalityId of municipalityIds) {
await toggleMunicipalityAdvisorMembership(payload, req, municipalityId, accountId, assigned)
}
const slugs = await municipalitySlugsOf(payload, req, municipalityIds)
return { accountId, slugs }
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização da carteira.' },
)
@ -223,12 +351,310 @@ export const setPersonAssessoraMembershipRecord = async (
export const setPersonAssessoraMembership = async (input: PersonAssessoraMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
await setPersonAssessoraMembershipRecord(payload, actor, input)
const { accountId, slugs } = await setPersonAssessoraMembershipRecord(payload, actor, input)
revalidatePath('/campanha/pessoas', 'page')
revalidatePath('/campanha/assessores', 'page')
if (accountId !== null) revalidatePath(`/campanha/assessores/${accountId}`, 'page')
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
revalidateMunicipalityListPaths({ scope: 'list' })
}
/**
* C128 — the Lidera column of a person: person-centric lifecycle write. The
* first added municipality CREATES the leadership (contact + municipalities,
* under `canCreateLeadership` — an advisor may only create within his
* carteira); removing the last municipality DELETES the leadership with its
* vote pledges and invites (destructive exit, scope-guarded: an advisor needs
* EVERY current municipality in his carteira, and the confirmation dialog
* listed the manifest). The cap/floor contracts of the leadership collection
* stay enforced — the exit goes straight to the delete, never an empty
* update (`requireAtLeastOneMunicipality` would reject it).
*/
export const setPersonLeadershipMunicipalitiesRecord = async (
payload: Payload,
actor: CampaignUser,
input: PersonLeadershipMembershipInput,
) => {
const { contactId, municipalityIds, assigned } = personLeadershipMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
const leaderships = await payload.find({
collection: 'leadership',
where: { contact: { equals: contactId } },
depth: 0,
limit: 1,
pagination: false,
select: { id: true, municipalities: true },
user: currentActor,
overrideAccess: false,
})
const leadership = leaderships.docs[0] as Leadership | undefined
if (!leadership) {
if (!assigned) return { leadershipID: null, slugs: [] }
if (municipalityIds.length > MAX_LEADERSHIP_MUNICIPALITIES) {
throw new Error(LEADERSHIP_MUNICIPALITY_CAP_MESSAGE)
}
try {
// `canCreateLeadership` re-checks the advisor's carteira through the
// user-threaded create (every requested município must be in scope).
const created = await payload.create({
collection: 'leadership',
data: hookFilledCreateData<'leadership'>({
contact: contactId,
municipalities: municipalityIds,
}),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
const slugs = await municipalitySlugsOf(payload, req, municipalityIds)
return { leadershipID: created.id, slugs }
} catch (error) {
if (isUniqueLeadershipConflict(error)) {
throw new Error(LEADERSHIP_DUPLICATE_MESSAGE)
}
throw error
}
}
// The advisory lock guards the delta read AND the exit enumeration —
// same key the B34 chip cell holds for the same relation.
await acquireTextAdvisoryLocks(payload, req, [`leadership-municipalities:${leadership.id}`])
const fresh = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
select: { municipalities: true },
user: currentActor,
overrideAccess: false,
req,
})
const currentMunicipalityIDs = uniqueRelationshipIds(fresh.municipalities)
if (!assigned) {
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
if (next.length === 0) {
// Destructive exit: the dialog already listed what this removes
// (declared votes, invites); the scope guard is the server-side
// re-check. Intentional admin bypasses: the exits delete by id
// (hooks) after the scope guard — same order as `personDelete.ts`.
await assertPersonCapacityExitScope(payload, currentActor, req, currentMunicipalityIDs)
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
await payload.delete({
collection: 'votePledge',
where: { leadership: { equals: leadership.id } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'campaignInvite',
where: { leadership: { equals: leadership.id } },
depth: 0,
// Intentional admin bypass: same exit guard as the pledge delete.
overrideAccess: true,
req,
})
await payload.delete({
collection: 'leadership',
id: leadership.id,
depth: 0,
// Intentional admin bypass: same exit guard as the pledge delete.
overrideAccess: true,
req,
})
return { leadershipID: null, slugs }
}
}
const delta = await applyLeadershipMunicipalitiesDelta(
payload,
currentActor,
req,
leadership.id,
currentMunicipalityIDs,
municipalityIds,
assigned,
)
return { leadershipID: delta.leadership ? leadership.id : null, slugs: delta.slugs }
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização da liderança.' },
)
}
export const setPersonLeadershipMunicipalities = async (input: PersonLeadershipMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
const { leadershipID, slugs } = await setPersonLeadershipMunicipalitiesRecord(
payload,
actor,
input,
)
revalidatePath('/campanha/pessoas', 'page')
revalidatePath('/campanha/liderancas', 'page')
if (leadershipID !== null) revalidatePath(`/campanha/liderancas/${leadershipID}`, 'page')
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
}
/**
* C128 — the Aliada em column of a person: person-centric lifecycle write. The
* first added municipality CREATES the dobradinha (contact, auto-slug from the
* ficha name — a name already in use maps to the safe conflict message);
* removing the last municipality DELETES the dobradinha (destructive exit,
* scope-guarded as the leadership one). No extra confirmation for the exit:
* the dobradinha carries no campaign data of its own beyond the row, so the
* vínculo cleanup is automatic (rabbit hole decision of the intention plan).
*/
export const setPersonStateDeputyMunicipalitiesRecord = async (
payload: Payload,
actor: CampaignUser,
input: PersonStateDeputyMembershipInput,
) => {
const { contactId, municipalityIds, assigned } = personStateDeputyMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
// Same per-person serialization as the Assessora lifecycle: the
// destructive exit (last municipality) decides from an enumeration, and
// concurrent removals of distinct chips would leave a zombie dobradinha.
await acquireTextAdvisoryLocks(payload, req, [`person-state-deputy:${contactId}`])
const deputies = await payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactId } },
depth: 0,
limit: 1,
pagination: false,
user: currentActor,
overrideAccess: false,
})
const deputy = deputies.docs[0] as StateDeputy | undefined
if (!deputy) {
if (!assigned) return { stateDeputyID: null, slugs: [] }
// The name invariant (advisory-locked) keeps the historical
// unique-name rule at the StateDeputy boundary — same as the B157
// inline create.
const contact = await payload.findByID({
collection: 'contact',
id: contactId,
depth: 0,
select: { name: true },
// Intentional admin bypass: the staff gate above is the
// authorization; this read only resolves the dobradinha name.
overrideAccess: true,
req,
})
await assertStateDeputyNameAvailable(payload, req, contact.name ?? 'Contato')
let created
try {
created = await payload.create({
collection: 'stateDeputy',
data: hookFilledCreateData<'stateDeputy'>({ contact: contactId }),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
} catch (error) {
const conflict = mapStaffEntityConflict(error, stateDeputyPolicy)
if (conflict) throw conflict
throw error
}
const batch = await applyStateDeputyMunicipalitiesBatch(
payload,
currentActor,
req,
created.id,
municipalityIds,
true,
)
return { stateDeputyID: created.id, slugs: batch.slugs }
}
if (!assigned) {
const byDeputy = await municipalityIdsByStateDeputyIds(payload, [deputy.id])
const currentMunicipalityIDs = byDeputy.get(deputy.id) ?? []
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
if (next.length === 0) {
// Destructive exit — the vínculos clean up via the FKs (set-null on
// `municipality.stateDeputies` / `leadership.stateDeputies`), same
// contract as `personDelete.ts`. Intentional admin bypass: the
// delete runs by id (hooks) after the exit scope guard.
await assertPersonCapacityExitScope(payload, currentActor, req, currentMunicipalityIDs)
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
await payload.delete({
collection: 'stateDeputy',
id: deputy.id,
depth: 0,
overrideAccess: true,
req,
})
return { stateDeputyID: null, slugs }
}
}
const batch = await applyStateDeputyMunicipalitiesBatch(
payload,
currentActor,
req,
deputy.id,
municipalityIds,
assigned,
)
return {
stateDeputyID: batch.slugs.length > 0 ? deputy.id : null,
slugs: batch.slugs,
}
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização da dobradinha.' },
)
}
export const setPersonStateDeputyMunicipalities = async (
input: PersonStateDeputyMembershipInput,
) => {
const { payload, actor } = await getCampaignActionContext()
const { stateDeputyID, slugs } = await setPersonStateDeputyMunicipalitiesRecord(
payload,
actor,
input,
)
revalidatePath('/campanha/pessoas', 'page')
revalidatePath('/campanha/dobradinhas', 'page')
if (stateDeputyID !== null) revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
// Same surface as the B37 twin: the list and the B147 v2 render the
// dobradinha chips per município.
revalidateMunicipalityListPaths({ scope: 'list' })
}
/**
* C128 — read-only preview for the destructive-exit confirmation dialog
* (`PeopleCapacityExitDialog`): what the exit removes, verbatim. The same
* scope guards the exit write runs inside its transaction — the dialog can
* never describe a destruction the write would refuse. The actor is reloaded
* fresh (same discipline as every record function): a downgraded or removed
* role must not read the manifest the write would refuse.
*/
export const getPersonCapacityExitManifestAction = async (input: unknown) => {
const data = personCapacityExitSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
const currentActor =
data.capacity === 'account'
? await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE)
: await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE)
return loadPersonCapacityExitManifest(payload, currentActor, data)
}
/**
* C116 — the Assessorado column: the advisor delta is applied to EVERY entity
* of the person (leadership and/or dobradinha) in one transaction — the column

View file

@ -9,7 +9,6 @@ import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE,
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_INVALID_CONTACT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
municipalityStateDeputyCreateSchema,
@ -34,22 +33,16 @@ import {
reloadStaffActor,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import {
mapStaffEntityConflict,
runStaffEntityMutation,
type StaffEntityPolicy,
} from '@/utilities/campaignEntityActions'
import { mapStaffEntityConflict, runStaffEntityMutation } from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
const stateDeputyPolicy: StaffEntityPolicy = {
staffMessage: STATE_DEPUTY_STAFF_MESSAGE,
conflictPattern: /state_deputy_(contact|slug)|duplicate key/i,
conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE,
}
import { stateDeputyPolicy } from '@/utilities/stateDeputyConflict'
type StateDeputyCreationData = {
name: string
@ -375,6 +368,76 @@ export const setStateDeputyAdvisorMembership = async (input: StateDeputyAdvisorM
return stateDeputyID
}
/**
* C128 — the B37 municipalities batch core, transaction-agnostic: toggles a
* dobradinha on `municipality.stateDeputies` for a set of municipalities and
* returns the changed slugs. Shared by `/campanha/dobradinhas` (B37) and the
* person lifecycle (`setPersonStateDeputyMunicipalitiesRecord`) so the two
* surfaces can never disagree on the batch contract. The caller must hold the
* fresh staff actor; the per-municipality advisory locks live here.
*/
export const applyStateDeputyMunicipalitiesBatch = async (
payload: Payload,
currentActor: CampaignUser,
req: PayloadTransactionRequest,
stateDeputyId: number,
municipalityIds: readonly number[],
assigned: boolean,
): Promise<{ slugs: string[] }> => {
// Already deduped by the schema's `.transform`; sorted only so a batch applies
// and reports in a stable order — `acquireTextAdvisoryLocks` sorts its own keys,
// so the deadlock-avoidance ordering does not depend on this line.
const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right)
await acquireTextAdvisoryLocks(
payload,
req,
uniqueMunicipalityIds.map((id) => `municipality-state-deputies:${id}`),
)
// Every município actually changed, not just the last one — same bug
// class the B34 twin fixed: a território chip can touch up to 435
// municípios in one call, and revalidating only the last would leave
// the rest of the detail pages stale.
const changedSlugs: string[] = []
for (const municipalityId of uniqueMunicipalityIds) {
// Row + field access verify the município is in the actor's scope
// (`canUpdateMunicipality` for advisors, `canManageCampaignStaffField`
// for the field) — an out-of-scope município throws here.
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { stateDeputies: true, slug: true },
user: currentActor,
overrideAccess: false,
req,
})
const currentStateDeputyIDs = uniqueRelationshipIds(municipality.stateDeputies)
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
currentStateDeputyIDs,
stateDeputyId,
assigned,
)
if (nextStateDeputyIDs === null) continue
await payload.update({
collection: 'municipality',
id: municipalityId,
data: { stateDeputies: nextStateDeputyIDs },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
}
return { slugs: changedSlugs }
}
export const setStateDeputyMunicipalitiesBatchRecord = async (
payload: Payload,
actor: CampaignUser,
@ -382,65 +445,24 @@ export const setStateDeputyMunicipalitiesBatchRecord = async (
) => {
const { stateDeputyId, municipalityIds, assigned } =
stateDeputyMunicipalitiesBatchSchema.parse(input)
// Already deduped by the schema's `.transform`; sorted only so a batch applies
// and reports in a stable order — `acquireTextAdvisoryLocks` sorts its own keys,
// so the deadlock-avoidance ordering does not depend on this line.
const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
await acquireTextAdvisoryLocks(
const result = await applyStateDeputyMunicipalitiesBatch(
payload,
currentActor,
req,
uniqueMunicipalityIds.map((id) => `municipality-state-deputies:${id}`),
stateDeputyId,
municipalityIds,
assigned,
)
// Every município actually changed, not just the last one — same bug
// class the B34 twin fixed: a território chip can touch up to 435
// municípios in one call, and revalidating only the last would leave
// the rest of the detail pages stale.
const changedSlugs: string[] = []
for (const municipalityId of uniqueMunicipalityIds) {
// Row + field access verify the município is in the actor's scope
// (`canUpdateMunicipality` for advisors, `canManageCampaignStaffField`
// for the field) — an out-of-scope município throws here.
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { stateDeputies: true, slug: true },
user: currentActor,
overrideAccess: false,
req,
})
const currentStateDeputyIDs = uniqueRelationshipIds(municipality.stateDeputies)
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
currentStateDeputyIDs,
stateDeputyId,
assigned,
)
if (nextStateDeputyIDs === null) continue
await payload.update({
collection: 'municipality',
id: municipalityId,
data: { stateDeputies: nextStateDeputyIDs },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
}
return {
stateDeputyID: changedSlugs.length > 0 ? stateDeputyId : undefined,
slugs: changedSlugs,
stateDeputyID: result.slugs.length > 0 ? stateDeputyId : undefined,
slugs: result.slugs,
}
},
{ beginFailureMessage: 'Não foi possível atualizar os municípios da dobradinha.' },

View file

@ -0,0 +1,122 @@
'use client'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import type { PersonCapacityExitManifest } from '@/utilities/people/personCapacityExit'
type PeopleCapacityExitDialogProps = {
open: boolean
personName: string
manifest: PersonCapacityExitManifest
onConfirm: () => void
onCancel: () => void
}
const plural = (count: number, singular: string, pluralForm?: string): string =>
`${count} ${count === 1 ? singular : (pluralForm ?? `${singular}s`)}`
/**
* C128 — the destructive-exit confirmation of the people list: when the LAST
* municipality of a capacity leaves the row, the entity (staff account or
* leadership) dies server-side. This dialog lists — verbatim from the
* read-only manifest — everything the transactional exit removes, and only
* then lets the guard resolve the pending commit (same pattern as
* `DeletePersonButton`; a cancel resolves the guard to `false` and nothing is
* touched).
*/
export const PeopleCapacityExitDialog = ({
open,
personName,
manifest,
onConfirm,
onCancel,
}: PeopleCapacityExitDialogProps) => {
const isLeadership = manifest.capacity === 'leadership'
return (
<AlertDialog open={open} onOpenChange={(next) => !next && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isLeadership ? 'Encerrar liderança' : 'Encerrar assessoria'}
</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Será removida a capacidade de{' '}
<span className="font-medium text-foreground">{personName}</span>:
</AlertDialogDescription>
</AlertDialogHeader>
{isLeadership ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
<li>Liderança (municípios: {manifest.municipalityNames.join(' · ') || '—'})</li>
<li>{plural(manifest.declaredVoteCount, 'voto declarado', 'votos declarados')}</li>
{manifest.inviteCount > 0 ? (
<li>{plural(manifest.inviteCount, 'convite pendente', 'convites pendentes')}</li>
) : null}
</ul>
) : (
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
<li>Conta de acesso ({manifest.accountName})</li>
{manifest.authored.inviteCount > 0 ? (
<li>{plural(manifest.authored.inviteCount, 'convite criado', 'convites criados')}</li>
) : null}
{manifest.authored.updateCount > 0 ? (
<li>
{plural(
manifest.authored.updateCount,
'atualização de município',
'atualizações de município',
)}
</li>
) : null}
{manifest.authored.feedCount > 0 ? (
<li>{plural(manifest.authored.feedCount, 'link de agenda', 'links de agenda')}</li>
) : null}
{manifest.authored.importBatchCount > 0 ? (
<li>
{plural(
manifest.authored.importBatchCount,
'importação de apoiadores pendente',
'importações de apoiadores pendentes',
)}
</li>
) : null}
{manifest.assessorado.leadershipNames.length > 0 ||
manifest.assessorado.deputyNames.length > 0 ||
manifest.assessorado.activityNames.length > 0 ? (
<li>
Deixa de ser assessor responsável
{manifest.assessorado.leadershipNames.length > 0
? ` de ${manifest.assessorado.leadershipNames.join(', ')}`
: ''}
{manifest.assessorado.deputyNames.length > 0
? `${manifest.assessorado.leadershipNames.length > 0 ? ' e' : ' de'} ${manifest.assessorado.deputyNames.join(', ')}`
: ''}
{manifest.assessorado.activityNames.length > 0
? `${manifest.assessorado.leadershipNames.length > 0 || manifest.assessorado.deputyNames.length > 0 ? ' e' : ' de'} ${manifest.assessorado.activityNames.join(', ')}`
: ''}
.
</li>
) : null}
</ul>
)}
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11">
Cancelar
</AlertDialogCancel>
<Button type="button" variant="destructive" className="min-h-11" onClick={onConfirm}>
{isLeadership ? 'Encerrar liderança' : 'Encerrar assessoria'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,25 +1,37 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { getPersonCapacityExitManifestAction } from '@/app/(campaign)/campanha/actions/person'
import { PeopleCapacityExitDialog } from '@/components/campaign/people/PeopleCapacityExitDialog'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import {
buildMunicipalityPortfolioChips,
type MunicipalityPortfolioIndexEntry,
} from '@/lib/municipalityPortfolio'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { PersonCapacityExitManifest } from '@/utilities/people/personCapacityExit'
type PeopleMunicipalityCellProps = {
/** The entity the write targets (`leadership.id`, `stateDeputy.id` or the staff account). */
ownerId: number | null
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
ownerName: string
/** C128 — the person ficha; the person-centric server actions resolve by it. */
contactId: number
/**
* C128 — the capacity lifecycle policy of the column: when the LAST
* municipality leaves, `account` and `leadership` confirm the destructive
* exit through `PeopleCapacityExitDialog`; `stateDeputy` commits straight
* (the row cleanup is automatic, no own campaign data — intention rabbit
* hole). Omit for columns without an entity lifecycle.
*/
exitMode?: 'account' | 'leadership' | 'stateDeputy'
municipalityIds: number[]
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
/** Ids the actor may ADD (administered carteira for an advisor). */
addableIds?: ReadonlySet<number>
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
minItems?: number
/** Read-only chips (actor may see the relation but not edit it). */
readOnly?: boolean
commitAction: (
@ -30,21 +42,34 @@ type PeopleMunicipalityCellProps = {
updateErrorMessage: string
}
const mapManifestError = (error: unknown): string => {
const message = error instanceof Error ? error.message : ''
return message || 'Não foi possível carregar o que será encerrado. Tente de novo.'
}
/**
* C116 — the people-list municipality columns (Assessora, Lidera, Aliada em):
* the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
* C116/C128 — the people-list municipality columns (Assessora, Lidera, Aliada
* em): the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
* always-input paradigm) plus the batch-chip expansion state — clicking a
* territory / "Salvador (19)" chip expands it into its member municipalities
* IN PLACE, and leaving the cell with the mouse collapses complete batches
* again. Expansion is local presentation: it never touches the commit cycle.
*
* C128 — the destructive exit: when a removal would EMPTY the relation, the
* commit is paused by `commitGuard` and, for `account`/`leadership`, the
* confirmation dialog lists the manifest before the commit proceeds. The
* entity (staff account / leadership / dobradinha) is created or deleted by
* the person-centric server action; the cell commits even with a `null` owner
* (the entity does not exist yet).
*/
export const PeopleMunicipalityCell = ({
ownerId,
ownerName,
contactId,
exitMode,
municipalityIds,
municipalityIndex,
addableIds,
minItems,
readOnly = false,
commitAction,
drawerTitle,
@ -54,6 +79,27 @@ export const PeopleMunicipalityCell = ({
const idsRef = useRef(municipalityIds)
idsRef.current = municipalityIds
/** The pending destructive exit: the dialog is up, the commit waits for it. */
const [exitRequest, setExitRequest] = useState<PersonCapacityExitManifest | null>(null)
const exitResolverRef = useRef<((confirmed: boolean) => void) | null>(null)
/** A manifest round-trip in flight — a second guard must not orphan the first. */
const manifestPendingRef = useRef(false)
/** Stable identity for the FormData extra-field map (memoized per contact). */
const extraFormFields = useMemo(() => ({ contactId }), [contactId])
/**
* Unmounting with the dialog open must settle the pending guard (resolve
* `false` — nothing was confirmed, nothing commits) instead of leaving the
* promise orphaned and the gesture silently lost.
*/
useEffect(() => {
return () => {
exitResolverRef.current?.(false)
exitResolverRef.current = null
}
}, [])
const toggleExpand = useCallback((chipKey: string) => {
setExpandedKeys((current) => {
const next = new Set(current)
@ -89,6 +135,72 @@ export const PeopleMunicipalityCell = ({
})
}, [municipalityIndex])
/**
* C128 — the destructive-exit guard, wired into `RelationChipCell` BEFORE
* the optimistic apply: a removal that empties the relation pauses the
* commit and (for account/leadership) asks the manifest-backed dialog.
* Fail-closed: a manifest error aborts the commit — nothing is ever deleted
* without an explicit confirmation. Returns `'destructive'` when the dialog
* confirmed the exit, so the shared cell suppresses the batch-removal undo
* toast — its "Desfazer" would only re-create the destroyed entity empty.
*/
const commitGuard = useCallback(
async (delta: {
changedIds: number[]
assigned: boolean
currentIds: number[]
}): Promise<boolean | 'destructive'> => {
if (!exitMode || delta.assigned) return true
const remaining = delta.currentIds.filter((id) => !delta.changedIds.includes(id))
if (remaining.length > 0) return true
// The dobradinha carries no campaign data of its own beyond the row —
// the cleanup is automatic (intention rabbit hole), no dialog.
if (exitMode === 'stateDeputy') return true
// A second guard while a dialog is pending OR a manifest round-trip is
// in flight is refused: the pending commit must resolve first (two
// concurrent guards would overwrite each other's resolver and orphan
// the first commit silently).
if (exitResolverRef.current || manifestPendingRef.current) return false
manifestPendingRef.current = true
let manifest: PersonCapacityExitManifest | null
try {
manifest = await getPersonCapacityExitManifestAction({
capacity: exitMode,
contactId,
})
} catch (error) {
toast.error(mapManifestError(error))
return false
} finally {
manifestPendingRef.current = false
}
// Nothing to destroy (no entity) — the server no-ops the removal.
if (manifest === null) return true
// A leadership with no declared votes and no invites dies silently —
// there is nothing of its own to lose (intention: "com confirmação se
// houver votos declarados").
if (manifest.capacity === 'leadership') {
if (manifest.declaredVoteCount === 0 && manifest.inviteCount === 0) return true
}
const confirmed = await new Promise<boolean>((resolve) => {
exitResolverRef.current = resolve
setExitRequest(manifest)
})
return confirmed ? 'destructive' : false
},
[exitMode, contactId],
)
const resolveExit = useCallback((confirmed: boolean) => {
exitResolverRef.current?.(confirmed)
exitResolverRef.current = null
setExitRequest(null)
}, [])
return (
<div onMouseLeave={handleMouseLeave}>
<MunicipalityPortfolioCell
@ -97,7 +209,6 @@ export const PeopleMunicipalityCell = ({
municipalityIds={municipalityIds}
municipalityIndex={municipalityIndex}
addableIds={addableIds}
minItems={minItems}
commitAction={commitAction}
drawerTitle={drawerTitle}
updateErrorMessage={updateErrorMessage}
@ -106,7 +217,19 @@ export const PeopleMunicipalityCell = ({
onChipClick={toggleExpand}
overflowToggleLabel={(count) => `+${count}`}
readOnly={readOnly}
extraFormFields={extraFormFields}
commitWithNullOwner
commitGuard={exitMode ? commitGuard : undefined}
/>
{exitRequest ? (
<PeopleCapacityExitDialog
open
personName={ownerName}
manifest={exitRequest}
onConfirm={() => resolveExit(true)}
onCancel={() => resolveExit(false)}
/>
) : null}
</div>
)
}

View file

@ -88,6 +88,26 @@ type MunicipalityPortfolioCellProps = {
overflowToggleLabel?: (hiddenCount: number) => string
/** C116 — read-only chips (actor may see the relation but not edit it). */
readOnly?: boolean
/**
* C128 — extra static fields appended to every commit FormData (e.g.
* `contactId` for the person-centric people-list actions).
*/
extraFormFields?: Record<string, string | number>
/**
* C128 — commit even with a `null` owner: the people-list cells keep the
* null owner while the entity does not exist yet, and the server-side person
* action creates it. See `RelationChipCell.commitWithNullOwner`.
*/
commitWithNullOwner?: boolean
/**
* C128 — destructive-exit guard before the optimistic apply. See
* `RelationChipCell.commitGuard`.
*/
commitGuard?: (delta: {
changedIds: number[]
assigned: boolean
currentIds: number[]
}) => Promise<boolean | 'destructive'>
}
/**
@ -114,6 +134,9 @@ export const MunicipalityPortfolioCell = ({
onChipClick,
overflowToggleLabel,
readOnly = false,
extraFormFields,
commitWithNullOwner = false,
commitGuard,
}: MunicipalityPortfolioCellProps) => {
/**
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
@ -162,11 +185,14 @@ export const MunicipalityPortfolioCell = ({
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('ownerId', String(ownerId))
for (const [name, value] of Object.entries(extraFormFields ?? {})) {
formData.set(name, String(value))
}
formData.set('assigned', assigned ? 'true' : 'false')
for (const id of changedIds) formData.append('municipalityIds', String(id))
return formData
},
[ownerId],
[ownerId, extraFormFields],
)
const copy = useMemo<RelationChipCellCopy>(
@ -206,6 +232,8 @@ export const MunicipalityPortfolioCell = ({
overflowToggleLabel={overflowToggleLabel}
onChipClick={onChipClick ? (chip) => onChipClick(chip.key) : undefined}
readOnly={readOnly}
commitWithNullOwner={commitWithNullOwner}
commitGuard={commitGuard}
/>
)
}

View file

@ -187,6 +187,31 @@ type RelationChipCellProps = {
* "+N" chip label (e.g. "+3"), keeping the B170 measure/collapse contract.
*/
overflowToggleLabel?: (hiddenCount: number) => string
// C128 — the person lifecycle surface ───────────────────────────────────────
/**
* C128 — commit even when `ownerId` is `null`. The shared cells treat a null
* owner as "draft row, nothing to write"; the people-list cells keep the
* null owner on purpose (the entity does not exist yet) and the server-side
* person actions resolve/create the entity by `contactId` from the FormData.
*/
commitWithNullOwner?: boolean
/**
* C128 — destructive-exit guard: called on removals (and additions) BEFORE
* the optimistic apply, same point as the floor/cap refusals. Return `false`
* to abort the commit without touching the chip state (the wrapper opens its
* confirmation dialog), `true` to proceed, or `'destructive'` when the
* wrapper confirmed a destructive exit — the caller then suppresses the
* batch-removal undo toast, whose "Desfazer" would only re-create the
* destroyed entity empty (votes/account are gone). `currentIds` is the
* optimistic-aware effective set, so the wrapper can decide "does this
* removal empty the relation?".
*/
commitGuard?: (delta: {
changedIds: number[]
assigned: boolean
currentIds: number[]
}) => Promise<boolean | 'destructive'>
}
/**
@ -227,6 +252,8 @@ export const RelationChipCell = ({
quiet = false,
onChipClick,
overflowToggleLabel,
commitWithNullOwner = false,
commitGuard,
}: RelationChipCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@ -497,7 +524,7 @@ export const RelationChipCell = ({
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
* each compute from the set their own render captured and drop the other's work.
*/
const commit = (changedIds: number[], assigned: boolean) => {
const commit = (changedIds: number[], assigned: boolean, suppressUndoToast = false) => {
if (changedIds.length === 0) return
if (draft) {
@ -515,7 +542,7 @@ export const RelationChipCell = ({
setFeedback(null)
if (draft) return
if (ownerId === null) return
if (ownerId === null && !commitWithNullOwner) return
const formData = buildFormData(changedIds, assigned)
@ -525,8 +552,11 @@ export const RelationChipCell = ({
setFeedback({ kind: 'saved' })
// A batch removal drops several links in one tap, and the row it left
// behind carries no trace of what was there. Single removals are one
// search away from being restored and get no toast.
if (!assigned && changedIds.length > 1) {
// search away from being restored and get no toast. C128: a
// destructive exit (the wrapper's guard confirmed the entity dies)
// suppresses the toast — "Desfazer" would only re-create the destroyed
// entity empty.
if (!assigned && changedIds.length > 1 && !suppressUndoToast) {
toast.success(copy.removedMessage(changedIds.length), {
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
})
@ -542,6 +572,24 @@ export const RelationChipCell = ({
})
}
/**
* C128 — the destructive-exit guard runs here, BEFORE the optimistic apply
* (same point as the floor/cap refusals): a `false` aborts with no state
* touched — the wrapper's confirmation dialog decides. A `'destructive'`
* verdict suppresses the batch-removal undo toast below (re-adding would
* only re-create the destroyed entity empty — votes/account are gone). The
* undo-toast path calls `commit` directly and never passes the guard
* (re-adding can never empty a relation).
*/
const guardedCommit = async (changedIds: number[], assigned: boolean) => {
let verdict: boolean | 'destructive' = true
if (commitGuard) {
verdict = await commitGuard({ changedIds, assigned, currentIds: effectiveIds })
if (verdict === false) return
}
commit(changedIds, assigned, verdict === 'destructive')
}
/** The relation's floor, refused here so the server never has to say no. */
const removalFloorReason = (chip: RelationChip): string | null =>
assignedIds.size - chip.ids.length < minItems
@ -570,7 +618,7 @@ export const RelationChipCell = ({
toast.error(`${floorReason}.`)
return
}
commit(chip.ids, false)
void guardedCommit(chip.ids, false)
}
const pickHit = (hit: RelationSearchHit) => {
@ -579,7 +627,7 @@ export const RelationChipCell = ({
toast.error(capReason)
return
}
commit(hit.ids, true)
void guardedCommit(hit.ids, true)
}
/**

View file

@ -7,6 +7,10 @@ import { positiveRelationshipId } from '@/lib/schemas/primitives'
* (via the shared `contactFieldUpdateSchema`), the Assessora carteira batch
* (unrestricted-only, person with exactly ONE staff account) and the
* person-centric Assessorado advisor delta.
*
* C128 — the capacity-lifecycle memberships (Lidera / Aliada em) are
* person-centric by `contactId` (the entity is resolved, created or deleted by
* the server), and the destructive-exit manifest contract.
*/
export const personAssessoraMembershipSchema = z.object({
@ -15,14 +19,34 @@ export const personAssessoraMembershipSchema = z.object({
assigned: z.boolean(),
})
export const personLeadershipMembershipSchema = z.object({
contactId: positiveRelationshipId,
municipalityIds: z.array(positiveRelationshipId).min(1),
assigned: z.boolean(),
})
export const personStateDeputyMembershipSchema = z.object({
contactId: positiveRelationshipId,
municipalityIds: z.array(positiveRelationshipId).min(1),
assigned: z.boolean(),
})
export const personAdvisorMembershipSchema = z.object({
contactId: positiveRelationshipId,
advisorId: positiveRelationshipId,
assigned: z.boolean(),
})
export const personCapacityExitSchema = z.object({
capacity: z.enum(['account', 'leadership']),
contactId: positiveRelationshipId,
})
export type PersonAssessoraMembershipInput = z.input<typeof personAssessoraMembershipSchema>
export type PersonLeadershipMembershipInput = z.input<typeof personLeadershipMembershipSchema>
export type PersonStateDeputyMembershipInput = z.input<typeof personStateDeputyMembershipSchema>
export type PersonAdvisorMembershipInput = z.input<typeof personAdvisorMembershipSchema>
export type PersonCapacityExitInput = z.input<typeof personCapacityExitSchema>
/** Refusal messages shared verbatim between the server action and the cell. */
export const PERSON_CELL_STAFF_MESSAGE =
@ -31,9 +55,9 @@ export const PERSON_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta pessoa não está no seu e
export const PERSON_CONTACT_INVALID_MESSAGE = 'Esta pessoa não tem ficha de contato.'
export const PERSON_ASSESSORA_UNRESTRICTED_MESSAGE =
'Somente a coordenação e o candidato podem editar a carteira de assessores.'
export const PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE =
'Esta pessoa não tem perfil de assessor para vincular municípios.'
export const PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE =
'Esta pessoa tem mais de um perfil de assessor; edite os municípios por perfil no admin.'
export const PERSON_ADVISORS_UNRESTRICTED_MESSAGE =
'Somente a coordenação e o candidato podem editar os assessores.'
export const PERSON_CAPACITY_EXIT_SCOPE_MESSAGE =
'Você só pode encerrar a capacidade quando todos os municípios dela estão na sua carteira.'

View file

@ -0,0 +1,40 @@
import 'server-only'
import { stubCampaignUserEmailFor } from '@/lib/schemas/advisor'
import { MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE } from '@/lib/schemas/municipality'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import type { Payload } from 'payload'
/**
* C128 — the next free `<slug>@criado.invalid` stub for an account created
* without usable credentials (the "login is provisioned later" contract). Was
* private to `actions/municipality.ts` (B154); the person lifecycle
* (`setPersonAssessoraMembershipRecord`) creates staff accounts too, and the
* unique-email probe must never diverge between the two surfaces.
*
* Bounded exact-match probes on the unique `email` index (no LIKE semantics).
* Collisions only happen when two names slugify identically, so the first
* probe usually succeeds. The read bypasses access control on purpose: the
* caller's gate (unrestricted role) already ran in its transaction, and the
* probe only looks at placeholder e-mails.
*/
export const nextFreeStubCampaignEmail = async (
payload: Pick<Payload, 'find'>,
req: PayloadTransactionRequest,
name: string,
): Promise<string> => {
for (let occurrence = 1; occurrence <= 50; occurrence += 1) {
const candidate = stubCampaignUserEmailFor(name, occurrence)
const existing = await payload.find({
collection: 'campaignUser',
where: { email: { equals: candidate } },
depth: 0,
limit: 1,
select: { email: true },
overrideAccess: true,
req,
})
if (existing.totalDocs === 0) return candidate
}
throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE)
}

View file

@ -0,0 +1,21 @@
import 'server-only'
/**
* C128 — shared leadership identity-conflict matcher. Was private to
* `actions/leadership.ts` (create path); the person lifecycle
* (`setPersonLeadershipMunicipalitiesRecord`) creates leaderships too, and the
* `contact` unique violation must map to the same safe message in both
* surfaces. Lives outside the 'use server' module because a plain const cannot
* be re-exported from one (Next.js allows only async function exports there).
*/
export const isUniqueLeadershipConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
if (/leadership_contact|duplicate key/i.test(message)) {
return true
}
if (!(error instanceof Error) || error.name !== 'ValidationError') return false
return /contact(?:_id)?/i.test(JSON.stringify(error))
}

View file

@ -0,0 +1,280 @@
import 'server-only'
import type { Payload } from 'payload'
import { uniqueRelationshipIds } from '@/lib/relationship'
import {
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
type PersonCapacityExitInput,
} from '@/lib/schemas/personCell'
import type { CampaignUser, StateDeputy } from '@/payload-types'
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
/**
* C128 — the destructive-exit contract of the people list: when the LAST
* municipality of a capacity leaves the row, the entity (staff account,
* leadership, dobradinha) dies server-side. Two guards, shared by the exit
* write (inside the transaction) and the confirmation manifest (read-only
* preview) so the dialog can never describe a destruction the write would
* refuse, or vice-versa.
*
* - Scope (Lidera / Aliada em): coordinator/candidate always pass; an advisor
* only passes when EVERY municipality the capacity currently holds is in his
* carteira — the chips visibility already prevents the gesture otherwise,
* this is the server-side re-check ("o piso dele é a visibilidade").
* - Account (Assessora): coordinator/candidate only — the intent's gate
* decision (criação/remoção de conta staff é coordenação/candidato).
*
* The manifest is a preview, never the authority: the exit write re-enumerates
* inside its transaction (same contract as `loadPersonDeleteManifest`).
*/
export type PersonCapacityExitManifest =
| {
capacity: 'leadership'
declaredVoteCount: number
inviteCount: number
municipalityNames: string[]
}
| {
capacity: 'account'
accountName: string
authored: {
inviteCount: number
updateCount: number
feedCount: number
importBatchCount: number
}
assessorado: { leadershipNames: string[]; deputyNames: string[]; activityNames: string[] }
}
type CapacityExitPayload = Pick<Payload, 'find' | 'count' | 'findByID'>
export const assertPersonCapacityExitScope = async (
payload: CapacityExitPayload,
currentActor: CampaignUser,
req: PayloadTransactionRequest | undefined,
currentMunicipalityIDs: readonly number[],
): Promise<void> => {
if (isCampaignUnrestricted(currentActor)) return
if (currentActor.role !== 'advisor') {
throw new Error(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
}
const administered = new Set(await getAdvisorMunicipalityIds(payload, currentActor.id, req))
const outside = currentMunicipalityIDs.filter((id) => !administered.has(id))
if (outside.length > 0) {
throw new Error(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
}
}
const assessoradoNamesOf = async (
payload: CapacityExitPayload,
accountID: number,
req: PayloadTransactionRequest | undefined,
): Promise<{ leadershipNames: string[]; deputyNames: string[]; activityNames: string[] }> => {
const [leaderships, deputies, activities] = await Promise.all([
payload.find({
collection: 'leadership',
where: { advisors: { contains: accountID } },
depth: 1,
limit: 0,
pagination: false,
select: { contact: true },
// Intentional admin bypass: the unrestricted gate (or the exit scope) is
// the authorization; the manifest only resolves names to display.
overrideAccess: true,
req,
}),
payload.find({
collection: 'stateDeputy',
where: { advisors: { contains: accountID } },
depth: 1,
limit: 0,
pagination: false,
select: { contact: true },
// Intentional admin bypass: same gate as the leadership read above.
overrideAccess: true,
req,
}),
payload.find({
collection: 'activity',
where: { responsible: { equals: { relationTo: 'campaignUser', value: accountID } } },
depth: 0,
limit: 0,
pagination: false,
select: { title: true },
// Intentional admin bypass: same gate as the leadership read above.
overrideAccess: true,
req,
}),
])
const nameOf = (contact: unknown): string =>
contact !== null && typeof contact === 'object' && 'name' in contact
? String((contact as { name: unknown }).name ?? 'Contato')
: 'Contato'
return {
leadershipNames: leaderships.docs.map((doc) => nameOf((doc as { contact: unknown }).contact)),
deputyNames: deputies.docs.map((doc) => nameOf((doc as StateDeputy).contact)),
activityNames: activities.docs
.map((doc) => (doc as { title?: string | null }).title ?? 'Atividade')
.filter(Boolean),
}
}
/**
* Read-only preview of what the destructive exit removes. `null` means there
* is nothing to destroy (no entity for the capacity) — the caller commits the
* plain municipality change (a server no-op) without a dialog. Throws on the
* scope refusals so the dialog never opens for a destruction that would fail.
*/
export const loadPersonCapacityExitManifest = async (
payload: CapacityExitPayload,
actor: CampaignUser,
input: PersonCapacityExitInput,
req?: PayloadTransactionRequest,
): Promise<PersonCapacityExitManifest | null> => {
if (input.capacity === 'account') {
if (!isCampaignUnrestricted(actor)) {
throw new Error(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
}
const accounts = await payload.find({
collection: 'campaignUser',
where: { contact: { equals: input.contactId } },
depth: 0,
limit: 2,
pagination: false,
select: { name: true },
// Intentional admin bypass: the unrestricted gate above is the
// authorization; `contact` is identity-gated and only counted here.
overrideAccess: true,
req,
})
if (accounts.docs.length === 0) return null
if (accounts.docs.length > 1) {
throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
}
const accountID = Number(accounts.docs[0].id)
const [invites, updates, feeds, batches, assessorado] = await Promise.all([
// Intentional admin bypass: the unrestricted gate above is the
// authorization; these counts only size the manifest list.
payload.count({
collection: 'campaignInvite',
where: { createdBy: { equals: accountID } },
overrideAccess: true,
req,
}),
payload.count({
collection: 'municipalityUpdate',
where: { author: { equals: accountID } },
// Intentional admin bypass: same unrestricted gate as above.
overrideAccess: true,
req,
}),
payload.count({
collection: 'calendarFeed',
where: { createdBy: { equals: accountID } },
// Intentional admin bypass: same unrestricted gate as above.
overrideAccess: true,
req,
}),
// Intentional admin bypass: same unrestricted gate as above.
payload.count({
collection: 'supporterImportBatch',
where: { actor: { equals: accountID } },
overrideAccess: true,
req,
}),
assessoradoNamesOf(payload, accountID, req),
])
return {
capacity: 'account',
accountName: accounts.docs[0].name,
authored: {
inviteCount: invites.totalDocs,
updateCount: updates.totalDocs,
feedCount: feeds.totalDocs,
importBatchCount: batches.totalDocs,
},
assessorado,
}
}
const leaderships = await payload.find({
collection: 'leadership',
where: { contact: { equals: input.contactId } },
depth: 0,
limit: 1,
pagination: false,
select: { id: true, municipalities: true },
user: actor,
overrideAccess: false,
})
if (leaderships.docs.length === 0) return null
const leadershipID = leaderships.docs[0].id
const currentMunicipalityIDs = uniqueRelationshipIds(
(leaderships.docs[0] as { municipalities: unknown[] }).municipalities,
)
await assertPersonCapacityExitScope(payload, actor, req, currentMunicipalityIDs)
const [pledges, invites, municipalities] = await Promise.all([
payload.find({
collection: 'votePledge',
where: { leadership: { equals: leadershipID } },
depth: 0,
limit: 0,
pagination: false,
select: { declaredVotes: true },
// Intentional admin bypass: the exit scope was checked above; the
// manifest only sums the votes the deletion would remove.
overrideAccess: true,
req,
}),
payload.count({
collection: 'campaignInvite',
where: { leadership: { equals: leadershipID } },
// Intentional admin bypass: same exit scope as the pledges read above.
overrideAccess: true,
req,
}),
payload.find({
collection: 'municipality',
where: { id: { in: currentMunicipalityIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
// Intentional admin bypass: same exit scope as the pledges read above.
overrideAccess: true,
req,
}),
])
const nameById = new Map(
municipalities.docs.map((municipality) => [municipality.id, municipality.name] as const),
)
return {
capacity: 'leadership',
declaredVoteCount: pledges.docs.reduce(
(sum, pledge) =>
sum +
(typeof (pledge as { declaredVotes?: number | null }).declaredVotes === 'number'
? ((pledge as { declaredVotes: number }).declaredVotes ?? 0)
: 0),
0,
),
inviteCount: invites.totalDocs,
municipalityNames: currentMunicipalityIDs
.map((id) => nameById.get(id))
.filter((name): name is string => name !== undefined),
}
}

View file

@ -332,6 +332,61 @@ export type PersonDeleteResult = {
deletedAccounts: number
}
/**
* Deletes ONE staff account with everything that would block the row: invites
* the account CREATED for other leaderships, authored municipality updates,
* calendar feeds and import batches all carry NOT NULL FKs with `ON DELETE set
* null` — they must go BEFORE the account itself. Per-id so
* `CampaignUser.beforeDelete` (passkeys + notifications) runs. The advisor
* join tables (`municipality_rels`, `activity_rels`, `leadership_rels`,
* `state_deputy_rels`) cascade on `campaign_user_id` on their own.
*
* Shared by the person cascade and the C128 "fim de assessoria" (last
* Assessora municipality removed) — the account lifecycle must never diverge
* between the two surfaces.
*/
export const deleteCampaignUserAccount = async (
payload: PersonDeletePayload,
req: PayloadTransactionRequest,
accountID: number,
): Promise<void> => {
await payload.delete({
collection: 'campaignInvite',
where: { createdBy: { equals: accountID } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'municipalityUpdate',
where: { author: { equals: accountID } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'calendarFeed',
where: { createdBy: { equals: accountID } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'supporterImportBatch',
where: { actor: { equals: accountID } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'campaignUser',
id: accountID,
depth: 0,
overrideAccess: true,
req,
})
}
/**
* The destructive cascade, one transaction, one advisory lock per person.
* Re-enumerates inside the transaction (the manifest is a preview, never the
@ -393,37 +448,14 @@ export const deletePersonRecord = async (
})
}
if (accountIDs.length > 0) {
// Invites the deleted accounts CREATED for other leaderships, authored
// municipality updates and calendar feeds all carry NOT NULL FKs with
// `ON DELETE set null` — they must go before the accounts themselves.
await payload.delete({
collection: 'campaignInvite',
where: { createdBy: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'municipalityUpdate',
where: { author: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'calendarFeed',
where: { createdBy: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'supporterImportBatch',
where: { actor: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs
// for every account — bulk `where` deletes would skip the hooks. The
// helper also removes the NOT NULL authored rows (invites created by the
// account, municipality updates, calendar feeds, import batches) before
// the account row itself.
for (const accountID of accountIDs) {
await deleteCampaignUserAccount(payload, req, accountID)
}
}
if (supporterCount > 0) {
await payload.delete({
@ -464,18 +496,6 @@ export const deletePersonRecord = async (
}
}
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs for
// every account — bulk `where` deletes would skip the hooks.
for (const accountID of accountIDs) {
await payload.delete({
collection: 'campaignUser',
id: accountID,
depth: 0,
overrideAccess: true,
req,
})
}
let contactDeleted = false
let contactAnonymized = false
if (signatureCount > 0 || subscriptionCount > 0) {

View file

@ -0,0 +1,23 @@
import 'server-only'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import type { StaffEntityPolicy } from '@/utilities/campaignEntityActions'
/**
* C128 — shared dobradinha identity-conflict policy. Was private to
* `actions/stateDeputy.ts`; the person lifecycle
* (`setPersonStateDeputyMunicipalitiesRecord`) creates dobradinhas too, and
* the `contact`/`slug` unique violations must map to the same safe message in
* both surfaces. Lives outside the 'use server' module because a plain const
* cannot be re-exported from one (Next.js allows only async function exports
* there).
*/
export const stateDeputyPolicy: StaffEntityPolicy = {
staffMessage: STATE_DEPUTY_STAFF_MESSAGE,
conflictPattern: /state_deputy_(contact|slug)|duplicate key/i,
conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE,
}

View file

@ -5,6 +5,8 @@ import {
test,
} from './fixtures/campaignE2EFixtures.js'
import type { CampaignUser } from '../../src/payload-types.js'
/**
* C100 — the unified people list: staff lands on `/campanha/pessoas`, sees the
* merged person row, and the route stays locked for leaders.
@ -366,10 +368,12 @@ test.describe('Pessoas — lista unificada', () => {
await page.goto(`${campaign.baseURL}/campanha/pessoas?q=${encodeURIComponent(contactName)}`)
await expect(campaignPageChrome(page, 'Pessoas')).toBeVisible()
// A leadership-only row has a single editable capacity cell, so the Lidera
// search input is the row's only combobox.
// C128 made every empty capacity cell editable, so a leadership-only row
// now shows one combobox per capacity — scope to the Lidera cell (4th td;
// the hidden-by-default E-mail column renders no td, B197).
const row = page.getByRole('row', { name: new RegExp(contactName) })
const search = row.getByRole('combobox', {
const lideraCell = row.locator('td').nth(3)
const search = lideraCell.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
})
await search.fill('salvador')
@ -414,4 +418,193 @@ test.describe('Pessoas — lista unificada', () => {
for (const zoneId of zoneIds) expect(stored.municipalities).toContain(zoneId)
expect(stored.municipalities).toContain(linked.id)
})
test('coordinator gives a person the first leadership municipality and ends it through the dialog (C128)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C128'),
})
const municipality = await fixtures.claimMunicipality()
// The person anchors on the list through a dobradinha — no leadership yet.
const contactName = fixtures.value('Ciclo Liderança')
const contact = await fixtures.payload.create({
collection: 'contact',
data: {
name: contactName,
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, slug: fixtures.value('ciclo-lideranca') },
depth: 0,
})
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(municipality.id)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
await omnibox.fill(contactName)
await omnibox.press('Enter')
const row = page.getByRole('row', { name: new RegExp(contactName) })
await expect(row).toBeVisible()
// First municipality in "Lidera" → the leadership is born.
// The hidden-by-default E-mail column renders no td (B197) — Lidera is the 4th cell.
const lideraCell = row.locator('td').nth(3)
await lideraCell
.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
})
.fill(municipality.name)
await page
.getByRole('option', {
name: new RegExp(`^${municipality.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`),
})
.click()
// Scoped to the Lidera cell: the dobradinha anchor may share the município.
await expect(lideraCell.getByText(municipality.name)).toBeVisible()
let leadershipId: number | null = null
await expect
.poll(async () => {
const leaderships = await fixtures.payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
})
leadershipId = leaderships.docs[0]?.id ?? null
return leaderships.docs.length
})
.toBe(1)
// A declared vote makes the exit destructive.
await fixtures.payload.create({
collection: 'votePledge',
data: { leadership: leadershipId!, municipality: municipality.id, declaredVotes: 250 },
depth: 0,
})
// Remove the last municipality → the destructive dialog lists the vote.
await page.reload()
await expect(row).toBeVisible()
await lideraCell.getByRole('button', { name: `Remover ${municipality.name}` }).click()
await expect(page.getByRole('heading', { name: 'Encerrar liderança' })).toBeVisible()
await expect(page.getByText('250 votos declarados')).toBeVisible()
await page.getByRole('button', { name: 'Encerrar liderança' }).click()
// The leadership (with its pledge) is gone; the dobradinha keeps the row.
await expect
.poll(async () => {
const remaining = await fixtures.payload.find({
collection: 'leadership',
where: { id: { equals: leadershipId! } },
depth: 0,
pagination: false,
})
return remaining.docs.length
})
.toBe(0)
const pledges = await fixtures.payload.find({
collection: 'votePledge',
where: { leadership: { equals: leadershipId! } },
depth: 0,
pagination: false,
})
expect(pledges.docs).toHaveLength(0)
await expect(row).toBeVisible()
})
test('the Assessora cell creates the staff account on the first municipality (C128)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Assessora C128'),
})
const municipality = await fixtures.claimMunicipality()
// The person anchors on the list through a dobradinha — no staff account.
const contactName = fixtures.value('Ciclo Assessora')
const contact = await fixtures.payload.create({
collection: 'contact',
data: {
name: contactName,
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, slug: fixtures.value('ciclo-assessora') },
depth: 0,
})
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(municipality.id)
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
await omnibox.fill(contactName)
await omnibox.press('Enter')
const row = page.getByRole('row', { name: new RegExp(contactName) })
await expect(row).toBeVisible()
// First municipality in "Assessora" → the staff account is born (C116
// regression: the cell commit used to fail on a missing `contactId`).
// The hidden-by-default E-mail column renders no td (B197) — Assessora is the 3rd cell.
const assessoraCell = row.locator('td').nth(2)
await assessoraCell
.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
})
.fill(municipality.name)
await page
.getByRole('option', {
name: new RegExp(`^${municipality.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`),
})
.click()
let createdAccount: CampaignUser | undefined
await expect
.poll(async () => {
const accounts = await fixtures.payload.find({
collection: 'campaignUser',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
})
createdAccount = accounts.docs[0]
return accounts.docs.length
})
.toBe(1)
expect(createdAccount!.role).toBe('advisor')
expect(createdAccount!.email ?? '').toMatch(/@criado\.invalid$/)
})
})

View file

@ -7,6 +7,8 @@ import { beforeAll, describe, expect, it } from 'vitest'
import {
setPersonAdvisorMembershipRecord,
setPersonAssessoraMembershipRecord,
setPersonLeadershipMunicipalitiesRecord,
setPersonStateDeputyMunicipalitiesRecord,
updatePersonContactRecord,
} from '@/app/(campaign)/campanha/actions/person'
import { primaryPhoneOf } from '@/lib/phone'
@ -14,12 +16,14 @@ import { uniqueRelationshipIds } from '@/lib/relationship'
import {
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CELL_STAFF_MESSAGE,
} from '@/lib/schemas/personCell'
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
import config from '@/payload.config'
import { loadPersonCapacityExitManifest } from '@/utilities/people/personCapacityExit'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -186,6 +190,137 @@ describe('C116 — cell edits of the people list', () => {
expect(uniqueRelationshipIds(updatedFirst.advisors)).not.toContain(account.id)
})
it('creates the account on the first municipality (C128 lifecycle)', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const first = await fixtures.getMunicipality()
await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [first.id],
assigned: true,
})
// The account is born with the person's ficha, role advisor and a
// placeholder e-mail — no usable credentials, login provisioned later.
const accounts = await payload.find({
collection: 'campaignUser',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(accounts.docs).toHaveLength(1)
const account = accounts.docs[0]
expect(account.role).toBe('advisor')
expect(account.username).toBeFalsy()
expect(account.email).toMatch(/@criado\.invalid$/)
const updated = await payload.findByID({
collection: 'municipality',
id: first.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(uniqueRelationshipIds(updated.advisors)).toContain(account.id)
})
it('deletes the account when the last municipality leaves (C128 lifecycle)', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const only = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(only, [account])
await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [only.id],
assigned: false,
})
const remaining = await payload.find({
collection: 'campaignUser',
where: { id: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(remaining.docs).toHaveLength(0)
const updated = await payload.findByID({
collection: 'municipality',
id: only.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(uniqueRelationshipIds(updated.advisors)).not.toContain(account.id)
})
it('treats a removal on a person without an account as a no-op', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
const result = await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: false,
})
expect(result.accountId).toBeNull()
})
it('cascades authored rows when the account dies on the last municipality', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const only = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(only, [account])
const leadership = await fixtures.createLeadership({
contact: await fixtures.createContact(),
municipalities: [only],
})
await fixtures.createInvite({ leadership, createdBy: account })
await fixtures.createMunicipalityUpdate({ municipality: only, author: account })
await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [only.id],
assigned: false,
})
const remaining = await payload.find({
collection: 'campaignUser',
where: { id: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(remaining.docs).toHaveLength(0)
// The NOT NULL authored rows went down with the account (same order as
// the person cascade).
const invites = await payload.find({
collection: 'campaignInvite',
where: { createdBy: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(invites.docs).toHaveLength(0)
const updates = await payload.find({
collection: 'municipalityUpdate',
where: { author: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(updates.docs).toHaveLength(0)
})
it('refuses a person with more than one staff account', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
@ -203,21 +338,6 @@ describe('C116 — cell edits of the people list', () => {
).rejects.toThrow(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
})
it('refuses a person with no staff account', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await expect(
setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE)
})
it('denies an advisor actor', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
@ -234,6 +354,401 @@ describe('C116 — cell edits of the people list', () => {
})
})
describe('setPersonLeadershipMunicipalitiesRecord (C128 lifecycle)', () => {
it('creates the leadership on the first municipality', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const first = await fixtures.getMunicipality()
await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [first.id],
assigned: true,
})
const leaderships = await payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(leaderships.docs).toHaveLength(1)
expect(uniqueRelationshipIds(leaderships.docs[0].municipalities)).toContain(first.id)
})
it('lets an advisor create the leadership inside his carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact()
await setPersonLeadershipMunicipalitiesRecord(payload, advisor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
})
const leaderships = await payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(leaderships.docs).toHaveLength(1)
})
it('deletes the leadership with its pledges and invites on the last municipality', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await fixtures.createVotePledge({ leadership, municipality, declaredVotes: 250 })
const staff = await fixtures.createCampaignUser('advisor')
await fixtures.createInvite({ leadership, createdBy: staff })
await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: false,
})
const remaining = await payload.find({
collection: 'leadership',
where: { id: { equals: leadership.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(remaining.docs).toHaveLength(0)
const pledges = await payload.find({
collection: 'votePledge',
where: { leadership: { equals: leadership.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(pledges.docs).toHaveLength(0)
const invites = await payload.find({
collection: 'campaignInvite',
where: { leadership: { equals: leadership.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(invites.docs).toHaveLength(0)
})
it('lets an advisor empty a leadership fully inside his carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await setPersonLeadershipMunicipalitiesRecord(payload, advisor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: false,
})
const remaining = await payload.find({
collection: 'leadership',
where: { id: { equals: leadership.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(remaining.docs).toHaveLength(0)
})
it('refuses an advisor emptying a leadership with municipalities outside his carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const inScope = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(inScope, [advisor])
const outside = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
await fixtures.createLeadership({ contact, municipalities: [inScope, outside] })
// The whole capacity in one batch: with `outside` out of the carteira,
// the destructive exit must be refused server-side (the client never
// renders that chip, but the server re-checks).
await expect(
setPersonLeadershipMunicipalitiesRecord(payload, advisor, {
contactId: contact.id,
municipalityIds: [inScope.id, outside.id],
assigned: false,
}),
).rejects.toThrow(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
})
it('treats a removal on a person without a leadership as a no-op', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
const result = await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: false,
})
expect(result.leadershipID).toBeNull()
})
it('resolves an add on a person whose leadership already exists as a no-op', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.createLeadership({ contact, municipalities: [municipality] })
const result = await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
})
expect(result.leadershipID).toBeNull()
})
it('re-creates the leadership when the undo toast re-adds after the exit', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: false,
})
const gone = await payload.find({
collection: 'leadership',
where: { id: { equals: leadership.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(gone.docs).toHaveLength(0)
// The undo path re-adds (`assigned=true`) — the lifecycle must bring the
// entity back (fresh, empty).
await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
})
const reborn = await payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(reborn.docs).toHaveLength(1)
expect(uniqueRelationshipIds(reborn.docs[0].municipalities)).toContain(municipality.id)
})
})
describe('setPersonStateDeputyMunicipalitiesRecord (C128 lifecycle)', () => {
it('creates the dobradinha on the first municipality with an auto-slug', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const first = await fixtures.getMunicipality()
await setPersonStateDeputyMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [first.id],
assigned: true,
})
const deputies = await payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(deputies.docs).toHaveLength(1)
expect(deputies.docs[0].slug).toBeTruthy()
const updated = await payload.findByID({
collection: 'municipality',
id: first.id,
depth: 0,
select: { stateDeputies: true },
overrideAccess: true,
})
expect(uniqueRelationshipIds(updated.stateDeputies)).toContain(deputies.docs[0].id)
})
it('deletes the dobradinha and its municipality links on the last municipality', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const only = await fixtures.getMunicipality()
const deputy = await fixtures.createStateDeputy({ contact })
await payload.update({
collection: 'municipality',
id: only.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
await setPersonStateDeputyMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [only.id],
assigned: false,
})
const remaining = await payload.find({
collection: 'stateDeputy',
where: { id: { equals: deputy.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(remaining.docs).toHaveLength(0)
const updated = await payload.findByID({
collection: 'municipality',
id: only.id,
depth: 0,
select: { stateDeputies: true },
overrideAccess: true,
})
expect(uniqueRelationshipIds(updated.stateDeputies)).not.toContain(deputy.id)
})
it('refuses a dobradinha whose name is already taken', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
// The second ficha must carry the same name to trip the unique-name rule.
const existing = await fixtures.createContact()
await fixtures.createStateDeputy({ contact: existing })
const contact = await fixtures.createContact({ name: existing.name })
await expect(
setPersonStateDeputyMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
})
})
describe('loadPersonCapacityExitManifest (C128 destructive exit)', () => {
it('lists the declared votes and invites of a leadership', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await fixtures.createVotePledge({ leadership, municipality, declaredVotes: 250 })
const staff = await fixtures.createCampaignUser('advisor')
await fixtures.createInvite({ leadership, createdBy: staff })
const manifest = await loadPersonCapacityExitManifest(payload, actor, {
capacity: 'leadership',
contactId: contact.id,
})
expect(manifest).toMatchObject({
capacity: 'leadership',
declaredVoteCount: 250,
inviteCount: 1,
})
})
it('returns an empty-but-present manifest for a leadership without votes or invites', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.createLeadership({ contact, municipalities: [municipality] })
const manifest = await loadPersonCapacityExitManifest(payload, actor, {
capacity: 'leadership',
contactId: contact.id,
})
expect(manifest).toMatchObject({
capacity: 'leadership',
declaredVoteCount: 0,
inviteCount: 0,
})
})
it('lists the account, authored rows and assessorado links of a staff person', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const municipality = await fixtures.getMunicipality()
const leadership = await fixtures.createLeadership({
contact: await fixtures.createContact(),
municipalities: [municipality],
})
await fixtures.createInvite({ leadership, createdBy: account })
await fixtures.createMunicipalityUpdate({ municipality, author: account })
const manifest = await loadPersonCapacityExitManifest(payload, actor, {
capacity: 'account',
contactId: contact.id,
})
expect(manifest).toMatchObject({
capacity: 'account',
accountName: account.name,
authored: { inviteCount: 1, updateCount: 1, feedCount: 0, importBatchCount: 0 },
})
})
it('refuses an advisor the account manifest', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const contact = await fixtures.createContact()
await fixtures.createCampaignUser('advisor', { phone: contact.phone })
await expect(
loadPersonCapacityExitManifest(payload, advisor, {
capacity: 'account',
contactId: contact.id,
}),
).rejects.toThrow(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
})
it('is null when the capacity has no entity', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const manifest = await loadPersonCapacityExitManifest(payload, actor, {
capacity: 'leadership',
contactId: contact.id,
})
expect(manifest).toBeNull()
})
})
describe('setPersonAdvisorMembershipRecord', () => {
it('writes the advisor delta on EVERY entity of the person (leadership + dobradinha)', async () => {
const fixtures = campaignFixtures()

View file

@ -415,6 +415,10 @@ describe('src/utilities top-level is pinned', () => {
'campaignBiometricsPrompt.ts',
'campaignColumnVisibilityCookie.ts',
'campaignConsent.ts',
// C128 — stub `<slug>@criado.invalid` e-mail probe for staff accounts
// created without usable credentials; shared by the B154 município create
// and the person lifecycle Assessora create.
'campaignStubEmail.ts',
'campaignDashboardData.ts',
'campaignVoteSummarySnapshot.ts',
'campaignDemandData.ts',
@ -459,6 +463,10 @@ describe('src/utilities top-level is pinned', () => {
'formatRelativeAge.ts',
'globalReads.ts',
'globals.ts',
// C128 — leadership identity-conflict matcher; shared by the B34 create
// path and the person lifecycle create (a plain const cannot leave the
// 'use server' action module).
'leadershipConflict.ts',
'hookFilledData.ts',
'leaderContactsPageData.ts',
'loadNamesByIds.ts',
@ -472,6 +480,10 @@ describe('src/utilities top-level is pinned', () => {
'sameOriginRequest.ts',
'seo.ts',
'signatureExport.ts',
// C128 — dobradinha identity-conflict policy; shared by the B157 create
// path and the person lifecycle create (same 'use server' export rule as
// `leadershipConflict.ts`).
'stateDeputyConflict.ts',
'stateDeputyData.ts',
'stateDeputyListFilters.ts',
'stateDeputyListUrl.ts',

View file

@ -0,0 +1,174 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
vi.mock('@/app/(campaign)/campanha/actions/person', () => ({
getPersonCapacityExitManifestAction: vi.fn(),
}))
import { getPersonCapacityExitManifestAction } from '@/app/(campaign)/campanha/actions/person'
vi.mock('sonner', () => ({
toast: {
success: () => {},
error: () => {},
},
}))
const INDEX: MunicipalityPortfolioIndexEntry[] = [
{ id: 1, slug: 'ilheus' },
{ id: 2, slug: 'itabuna' },
]
const manifestAction = vi.mocked(getPersonCapacityExitManifestAction)
let commitCalls: FormData[] = []
const success = async (): Promise<CampaignFormActionState> => ({ status: 'success' })
const cell = (
props: Partial<Parameters<typeof PeopleMunicipalityCell>[0]> & {
municipalityIds: number[]
},
) =>
createElement(PeopleMunicipalityCell, {
ownerId: null,
ownerName: 'Maria Souza',
contactId: 42,
municipalityIndex: INDEX,
commitAction: async (_state: CampaignFormActionState, formData: FormData) => {
commitCalls.push(formData)
return success()
},
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
...props,
})
const formEntry = (formData: FormData, name: string): string | null => {
const value = formData.get(name)
return typeof value === 'string' ? value : null
}
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterEach(() => {
commitCalls = []
manifestAction.mockReset()
cleanup()
})
afterAll(() => vi.unstubAllGlobals())
describe('C128 — people municipality cell lifecycle', () => {
it('commits an ADDITION with a null owner, carrying the contactId (C116 assessora bug fix)', async () => {
render(cell({ municipalityIds: [] }))
const search = screen.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
})
fireEvent.change(search, { target: { value: 'Itabuna' } })
await act(async () => {
fireEvent.click(screen.getByRole('option', { name: /Itabuna/ }))
await Promise.resolve()
})
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
const formData = commitCalls[0]!
expect(formEntry(formData, 'contactId')).toBe('42')
expect(formEntry(formData, 'ownerId')).toBe('null')
expect(formData.getAll('municipalityIds')).toEqual(['2'])
})
it('pauses the LAST removal behind the confirmation dialog and aborts on cancel', async () => {
manifestAction.mockResolvedValue({
capacity: 'account',
accountName: 'Maria Souza',
authored: { inviteCount: 0, updateCount: 0, feedCount: 0, importBatchCount: 0 },
assessorado: { leadershipNames: [], deputyNames: [], activityNames: [] },
})
render(cell({ municipalityIds: [1], exitMode: 'account' }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Encerrar assessoria' })).toBeTruthy(),
)
fireEvent.click(screen.getByRole('button', { name: 'Cancelar' }))
await waitFor(() =>
expect(screen.queryByRole('heading', { name: 'Encerrar assessoria' })).toBeNull(),
)
expect(commitCalls).toHaveLength(0)
// The optimistic apply never happened — the chip is still there.
expect(screen.getByText('Ilhéus')).toBeTruthy()
})
it('commits the LAST removal after the dialog confirms', async () => {
manifestAction.mockResolvedValue({
capacity: 'account',
accountName: 'Maria Souza',
authored: { inviteCount: 0, updateCount: 0, feedCount: 0, importBatchCount: 0 },
assessorado: { leadershipNames: [], deputyNames: [], activityNames: [] },
})
render(cell({ municipalityIds: [1], exitMode: 'account' }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Encerrar assessoria' })).toBeTruthy(),
)
fireEvent.click(screen.getByRole('button', { name: 'Encerrar assessoria' }))
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
expect(formEntry(commitCalls[0]!, 'contactId')).toBe('42')
expect(formEntry(commitCalls[0]!, 'assigned')).toBe('false')
})
it('skips the dialog for a leadership with nothing to lose', async () => {
manifestAction.mockResolvedValue({
capacity: 'leadership',
declaredVoteCount: 0,
inviteCount: 0,
municipalityNames: ['Ilhéus'],
})
render(cell({ municipalityIds: [1], exitMode: 'leadership' }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
expect(screen.queryByRole('heading', { name: 'Encerrar liderança' })).toBeNull()
})
it('skips the dialog for a stateDeputy exit (auto-cleanup)', async () => {
render(cell({ municipalityIds: [1], exitMode: 'stateDeputy' }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
expect(manifestAction).not.toHaveBeenCalled()
})
it('aborts the commit when the manifest fails (fail-closed)', async () => {
manifestAction.mockRejectedValue(new Error('Não foi possível carregar o que será encerrado.'))
render(cell({ municipalityIds: [1], exitMode: 'account' }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
await waitFor(() => expect(manifestAction).toHaveBeenCalled())
expect(commitCalls).toHaveLength(0)
expect(screen.getByText('Ilhéus')).toBeTruthy()
})
})