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 ? ( +
    +
  • Liderança (municípios: {manifest.municipalityNames.join(' · ') || '—'})
  • +
  • {plural(manifest.declaredVoteCount, 'voto declarado', 'votos declarados')}
  • + {manifest.inviteCount > 0 ? ( +
  • {plural(manifest.inviteCount, 'convite pendente', 'convites pendentes')}
  • + ) : null} +
+ ) : ( +
    +
  • Conta de acesso ({manifest.accountName})
  • + {manifest.authored.inviteCount > 0 ? ( +
  • {plural(manifest.authored.inviteCount, 'convite criado', 'convites criados')}
  • + ) : null} + {manifest.authored.updateCount > 0 ? ( +
  • + {plural( + manifest.authored.updateCount, + 'atualização de município', + 'atualizações de município', + )} +
  • + ) : null} + {manifest.authored.feedCount > 0 ? ( +
  • {plural(manifest.authored.feedCount, 'link de agenda', 'links de agenda')}
  • + ) : null} + {manifest.authored.importBatchCount > 0 ? ( +
  • + {plural( + manifest.authored.importBatchCount, + 'importação de apoiadores pendente', + 'importações de apoiadores pendentes', + )} +
  • + ) : null} + {manifest.assessorado.leadershipNames.length > 0 || + manifest.assessorado.deputyNames.length > 0 || + manifest.assessorado.activityNames.length > 0 ? ( +
  • + 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(', ')}` + : ''} + . +
  • + ) : null} +
+ )} + + + + 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 (
`+${count}`} readOnly={readOnly} + extraFormFields={extraFormFields} + commitWithNullOwner + commitGuard={exitMode ? commitGuard : undefined} /> + {exitRequest ? ( + resolveExit(true)} + onCancel={() => resolveExit(false)} + /> + ) : null}
) } diff --git a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx index 40948d64..4e0a66e3 100644 --- a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx +++ b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx @@ -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 + /** + * 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 } /** @@ -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( @@ -206,6 +232,8 @@ export const MunicipalityPortfolioCell = ({ overflowToggleLabel={overflowToggleLabel} onChipClick={onChipClick ? (chip) => onChipClick(chip.key) : undefined} readOnly={readOnly} + commitWithNullOwner={commitWithNullOwner} + commitGuard={commitGuard} /> ) } diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index 0acaa26f..3578b09b 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -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 } /** @@ -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) } /** diff --git a/src/lib/schemas/personCell.ts b/src/lib/schemas/personCell.ts index 7d2ec1ec..06b16e8c 100644 --- a/src/lib/schemas/personCell.ts +++ b/src/lib/schemas/personCell.ts @@ -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 +export type PersonLeadershipMembershipInput = z.input +export type PersonStateDeputyMembershipInput = z.input export type PersonAdvisorMembershipInput = z.input +export type PersonCapacityExitInput = z.input /** 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.' diff --git a/src/utilities/campaignStubEmail.ts b/src/utilities/campaignStubEmail.ts new file mode 100644 index 00000000..ea2cc614 --- /dev/null +++ b/src/utilities/campaignStubEmail.ts @@ -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 `@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, + 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 }, + overrideAccess: true, + req, + }) + if (existing.totalDocs === 0) return candidate + } + throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE) +} diff --git a/src/utilities/leadershipConflict.ts b/src/utilities/leadershipConflict.ts new file mode 100644 index 00000000..9b850e50 --- /dev/null +++ b/src/utilities/leadershipConflict.ts @@ -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)) +} diff --git a/src/utilities/people/personCapacityExit.ts b/src/utilities/people/personCapacityExit.ts new file mode 100644 index 00000000..614c35b7 --- /dev/null +++ b/src/utilities/people/personCapacityExit.ts @@ -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 + +export const assertPersonCapacityExitScope = async ( + payload: CapacityExitPayload, + currentActor: CampaignUser, + req: PayloadTransactionRequest | undefined, + currentMunicipalityIDs: readonly number[], +): Promise => { + 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 => { + 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), + } +} diff --git a/src/utilities/people/personDelete.ts b/src/utilities/people/personDelete.ts index 1061e5d7..7c445a0b 100644 --- a/src/utilities/people/personDelete.ts +++ b/src/utilities/people/personDelete.ts @@ -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 => { + 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) { diff --git a/src/utilities/stateDeputyConflict.ts b/src/utilities/stateDeputyConflict.ts new file mode 100644 index 00000000..1035170b --- /dev/null +++ b/src/utilities/stateDeputyConflict.ts @@ -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, +} diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index c9f2c969..5b4985fe 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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$/) + }) }) diff --git a/tests/int/campaignPersonCellEditing.int.spec.ts b/tests/int/campaignPersonCellEditing.int.spec.ts index f8a1dfda..2305665e 100644 --- a/tests/int/campaignPersonCellEditing.int.spec.ts +++ b/tests/int/campaignPersonCellEditing.int.spec.ts @@ -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() diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index a806c860..306ad65a 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -415,6 +415,10 @@ describe('src/utilities top-level is pinned', () => { 'campaignBiometricsPrompt.ts', 'campaignColumnVisibilityCookie.ts', 'campaignConsent.ts', + // C128 — stub `@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', diff --git a/tests/unit/peopleMunicipalityCell.unit.spec.tsx b/tests/unit/peopleMunicipalityCell.unit.spec.tsx new file mode 100644 index 00000000..d7a66a78 --- /dev/null +++ b/tests/unit/peopleMunicipalityCell.unit.spec.tsx @@ -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 => ({ status: 'success' }) + +const cell = ( + props: Partial[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() + }) +})