diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index b707b474..f4656697 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -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 `` 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).
diff --git a/docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md b/docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md
new file mode 100644
index 00000000..17b58a03
--- /dev/null
+++ b/docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md
@@ -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: , 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/` 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` (chamado em `attemptRemove`/`pickHit` antes do apply otimista; `false` aborta).
+- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os dois opt-ins; novo `extraFormFields?: Record` (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.
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
index 79f666c1..80d2fa32 100644
--- a/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
@@ -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 =>
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 =>
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.',
})
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
index acf718fa..d9f88b85 100644
--- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
@@ -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 | 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 = ({
{
- 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
type ValidatedLeadershipCreateData = Omit & {
@@ -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.' },
)
diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts
index e5c738c8..fe786430 100644
--- a/src/app/(campaign)/campanha/actions/municipality.ts
+++ b/src/app/(campaign)/campanha/actions/municipality.ts
@@ -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 `@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 => {
- 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)
-}
diff --git a/src/app/(campaign)/campanha/actions/person.ts b/src/app/(campaign)/campanha/actions/person.ts
index a2a6283a..79765496 100644
--- a/src/app/(campaign)/campanha/actions/person.ts
+++ b/src/app/(campaign)/campanha/actions/person.ts
@@ -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 => {
+ 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
diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts
index 72245a65..1ce08874 100644
--- a/src/app/(campaign)/campanha/actions/stateDeputy.ts
+++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts
@@ -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.' },
diff --git a/src/components/campaign/people/PeopleCapacityExitDialog.tsx b/src/components/campaign/people/PeopleCapacityExitDialog.tsx
new file mode 100644
index 00000000..13cb440c
--- /dev/null
+++ b/src/components/campaign/people/PeopleCapacityExitDialog.tsx
@@ -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 (
+ !next && onCancel()}>
+
+
+
+ {isLeadership ? 'Encerrar liderança' : 'Encerrar assessoria'}
+
+
+ Esta ação não pode ser desfeita. Será removida a capacidade de{' '}
+ {personName}:
+
+
+
+ {isLeadership ? (
+
+ )}
+
+
+
+ Cancelar
+
+
+
+
+
+ )
+}
diff --git a/src/components/campaign/people/PeopleMunicipalityCell.tsx b/src/components/campaign/people/PeopleMunicipalityCell.tsx
index b7d8f5b5..1f9279d1 100644
--- a/src/components/campaign/people/PeopleMunicipalityCell.tsx
+++ b/src/components/campaign/people/PeopleMunicipalityCell.tsx
@@ -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
- /** 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(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 => {
+ 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((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 (