mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #710 from fsolla/C128-pessoas-qualquer-pessoa-pode-assessorar-liderar-e-ou-do
C128 — Pessoas: qualquer pessoa pode assessorar, liderar e/ou dobrar — ciclo de vida nas tabelas pela lista
This commit is contained in:
commit
98f8ec9b9b
22 changed files with 2464 additions and 265 deletions
|
|
@ -13,6 +13,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
**Recently resolved (2026-08-11):** **OPS42** (Issue #691) — o e2e dev não perde mais fill/Enter para a **navegação RSC tardia do login**: em dev sob carga, ~150–500 ms após o load do `/campanha` o router do Next dev dispara um `history.replaceState` sem request (framenav) e remonta o subtree do layout (provider do chat novo, textarea novo com `defaultValue=''`) — interações imediatas caem na janela e são silenciosamente perdidas (input volta a `''`, envio nunca dispara; CI/prod imune — main verde). **Mecanismo medido por sonda** (3 runs nesta máquina, load 45–61): o `waitForURL` do login resolve **no commit** do redirect (não otimista — a hipótese da intenção caiu), e o commit perigoso é um **segundo** framenav pós-`goto` do próprio teste, cuja remontagem do DOM chega 150–250 ms depois; interação dentro da janela ainda induz cascade do overlay de dev (`POST /__nextjs_original-stack-frames`); fill atrasado ~2 s sobrevive. **Fix:** helper `waitForRouterSettled(page)` no fixture (`campaignE2EFixtures.ts`, padrão `checkRadixWhenHydrated`) — retorna após janela de **500 ms sem `framenavigated`** (fail-soft em 15 s com `console.warn`; **no-op em prod** via `CI`/`E2E_PROD` — custo zero no CI), chamado no `campaign.login` (pós-`waitForURL`) e nos pontos de interação imediata da família do chat: `openChatAndSend`/`openChat`/`askViaInput` em `campaignSollinhaContext`, `campaignAiChatResize`, `campaignAiLinks`, `campaignAiTranscribe`, `campaignAiChatFollowUps`; `openMobileDrawer` e os 2 cliques de drawer diretos de `campaignAiChatFollowUps`/`campaignAiChatOpeningChips` + o "Fechar" desktop do `campaignAiChatResize` (o run 3 pegou o clique do drawer na mesma janela — a classe é qualquer interação no layout, não só fill/Enter). **Rejeitadas:** retry de interação (double-send com o mock gated do B199), só-no-login (não enxerga o commit pós-goto), networkidle/wrap global de goto. **Evidência:** `campaignSollinhaContext` 10/10 ×3 dev (load 31–61; o B188 "reload na mesma aba" que flakeava 5/5 na sessão B199) + 8/8 prod (`E2E_PROD=1`, 30,7 s — no-op); irmão `campaignAiChatResize` 8/8 dev. Gates: tsc/lint/format/cycles verdes (knip falha pré-existente P3 — payload.config). Sem migration/access/Consent/UI/app. Intenção [`e2e-dev-router-quiescencia.md`](plans/e2e-dev-router-quiescencia.md); impl [`e2e-dev-router-quiescencia-impl.md`](plans/e2e-dev-router-quiescencia-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **C123** (Issue #667) — a agenda ganha **uma superfície única** de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com **todas** as seções do formulário completo; as páginas `/campanha/atividades/nova` e `/[slug]/editar` saem **sem redirecionamento** (404). `ActivityInlineCreate` virou `ActivityOverlay` (`ActivityOverlay.tsx`): slot click → criação com o intervalo do slot; **event click → edição** (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via `url` preservada); **`Local` renderizado uma única vez** (o bloco duplicado do popover C91 morreu); Início/Término com **data e hora separados e visíveis sem rolar** (`ActivityDateTimeField` reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil `YYYY-MM-DDTHH:mm` intacto; mobile `[dia][hora][minuto]` por linha). **Submit por FormData nas actions `createActivityOverlay`/`updateActivityOverlay`** (reusam os parsers `parseActivity*FormData` + os `*Record` transacionais; `status` só na criação — na edição não envia, evita realizado→confirmado) e **`loadActivityEditDraft`** (findByID `activityFormSelect`, `overrideAccess: false`, int testado por escopo). **Bug latente C14 consertado:** o `<input name="id">` que o update perdia (o `/editar` estava quebrado desde `adc29c03`) volta no modo edição. Quick actions `new-activity`/`edit-activity` viraram `onAction` bridged por contexto (`openActivityCreate`/`openActivityEdit`, precedente C94/C114); hosts novos: `ActivityCreateOverlayHost` (lista `/atividades`) e `ActivityEditOverlayHost` (detalhe, botão Editar + FAB). Morrem: páginas/formActions/`ActivityForm.tsx`, `buildActivityCreateHref`/`parseActivityCreatePrefill`/`parseActivityAgendaReturnHref` (activityUi), `atividadesNova` (chrome catalog), `ACTIVITY_NEW_PATH`, `getActivityEditPageData`, `createActivityInline` (vira `createActivityOverlay`), `activityInlineErrors` (vira `activityOverlayErrors`, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int `loadActivityEditDraftRecord` por papel, e2e `campaignActivity` 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; `E2E_PROD=1` é o modo de verificação). Intenção [`agenda-criacao-modal-central.md`](plans/agenda-criacao-modal-central.md); impl [`agenda-criacao-modal-central-impl.md`](plans/agenda-criacao-modal-central-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **C128** (Issue #695) — ciclo de vida das três capacidades pelas células de `/campanha/pessoas`: qualquer pessoa visível pode **ganhar** (primeira cidade cria a entidade) e **perder** (última cidade remove da tabela) as capacidades Assessora/Lidera/Aliada em — sem sair da lista. **Server:** três record functions **person-centric por `contactId`** em `actions/person.ts` — `setPersonAssessoraMembershipRecord` estendida (0 contas + add → **cria a conta staff**: `{ name, role: 'advisor', contact, email stub @criado.invalid, senha aleatória }` — sem credenciais utilizáveis, login provisionado depois; 0 contas + remove → no-op; última cidade → **delete da conta** com cascata de autorias), `setPersonLeadershipMunicipalitiesRecord` (cria liderança no primeiro município com `canCreateLeadership` re-checando a carteira; última cidade → delete de pledges→invites→liderança) e `setPersonStateDeputyMunicipalitiesRecord` (cria dobradinha com slug auto; última cidade → delete com vínculos limpos por FK). **Saída destrutiva por escopo, não por papel** (gate): coordenação/candidato sempre; **assessor também encerra Lidera/Aliada quando TODOS os municípios atuais da capacidade estão na carteira dele** (o piso dele é a visibilidade — chips fora nem renderizam; servidor re-checa `assertPersonCapacityExitScope`); Assessora segue coordenação/candidato (decisão do gate da intenção). Confirmação explícita com manifest (`getPersonCapacityExitManifestAction`/`loadPersonCapacityExitManifest` — votos declarados+convites para liderança, conta+autorias+assessorado para assessora; dobradinha sem diálogo, rabbit hole da intenção). **UI:** `RelationChipCell` ganhou opt-ins `commitWithNullOwner` e `commitGuard` (antes do apply otimista, fail-closed — falha de manifest aborta), `MunicipalityPortfolioCell` repassa + `extraFormFields` (manda `contactId`); `PeopleMunicipalityCell` implementa o guard + `PeopleCapacityExitDialog` (padrão DeletePersonButton); matriz da página: pessoa sem a capacidade fica editável (assessor com `addableIds`=carteira), `minItems` removido (o piso da liderança passa ao ciclo de vida). **Extrações (edit the owner):** `applyLeadershipMunicipalitiesDelta` (B34) e `applyStateDeputyMunicipalitiesBatch` (B37) viram helpers transaction-agnostic reusados pelos dois call sites; `deleteCampaignUserAccount` sai do `personDelete`; `nextFreeStubCampaignEmail` sai do municipality (B154); matchers de conflito `isUniqueLeadershipConflict`/`stateDeputyPolicy` movidos para utilities (const não sai de módulo 'use server'). **Bug latente C116 corrigido:** a célula Assessora nunca salvava — o FormData não enviava `contactId` que a action exigia (FormDataBoundaryError em toda tentativa); os int tests chamavam a record function direto e não pegavam. Sem migration. Testes: int `campaignPersonCellEditing` 28 (ciclo de vida das 3, manifest, guardas de escopo, no-ops, undo re-cria, conflito de nome da dobradinha), unit `peopleMunicipalityCell` 6 (guard aborta/confirma/fail-closed, commit com owner null + contactId), e2e `campaignPeople` +2 (cria liderança na célula + diálogo destrutivo com voto; conta staff criada na Assessora). Intenção [`pessoas-qualquer-capacidade-para-toda-pessoa.md`](plans/pessoas-qualquer-capacidade-para-toda-pessoa.md); impl [`pessoas-qualquer-capacidade-para-toda-pessoa-impl.md`](plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
|
||||
|
||||
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).
|
||||
|
|
|
|||
120
docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md
Normal file
120
docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa-impl.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Impl: Pessoas — qualquer pessoa pode assessorar, liderar e/ou dobrar — ciclo de vida nas tabelas pela lista
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #695
|
||||
Intenção: docs/plans/pessoas-qualquer-capacidade-para-toda-pessoa.md
|
||||
Appetite restante: ~2–3 dias eng; ciclo de vida completo das três capacidades num único recorte
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** em `/campanha/pessoas`, as três células de capacidade (Assessora, Lidera, Aliada em) ficam editáveis para toda pessoa visível — a primeira cidade adicionada cria a entidade na tabela da capacidade (conta staff / liderança / dobradinha) e a remoção da última cidade a remove daquela tabela; saídas destrutivas (votos declarados, conta de acesso) pedem confirmação explícita listando o que será removido (precedente da cascata "Apagar pessoa"). O ciclo de vida passa a garantir o piso de 1 município da liderança.
|
||||
- **O que NÃO negociar:** escopo por ator preservado (criação/remoção de conta staff = coordenação/candidato; assessor só o que está na carteira); leader fora da rota; lote inteiro = uma transação única (nada pela metade); as três capacidades independentes (criar/remover uma não toca as outras); contas staff pré-existentes com carteira vazia continuam como estão; contas vazias nascidas do ciclo de vida SÃO removidas (aceite); sem busca de Contatos sem capacidade; sem migration (escreve nas collections donas + `campaignUser`); copy pt-BR / identificadores em inglês.
|
||||
- **O que reavaliar:** a hipótese de "reusar `setLeadershipMunicipalitiesMembership`/`setStateDeputyMunicipalitiesBatch` nas células" — são escritas por `ownerId` (leadership/deputy id) e começam transação própria; o ciclo de vida precisa de **escritas person-centric por `contactId`** (mesmo padrão que C116 já usou em `setPersonAssessoraMembership`/`setPersonAdvisorMembership`), com a criação/remoção da entidade decidida por existência de vínculo. A hipótese de "extender as células compartilhadas" confirma-se (opt-ins com defaults intactos). **Bug latente C116 encontrado:** a célula Assessora nunca salva — `MunicipalityPortfolioCell.buildFormData` só envia `ownerId`, mas `setPersonAssessoraFormAction` exige `contactId` (FormDataBoundaryError "Campo obrigatório." em toda tentativa; int tests chamam a record function direto, então não pegaram). O refactor person-centric corrige.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S[schemas/personCell: membership por contactId + exit manifest] --> R[person.ts: 3 record functions person-centric]
|
||||
L[leadership.ts: extrair applyLeadershipMunicipalitiesDelta] --> R
|
||||
D[stateDeputy.ts: extrair applyStateDeputyMunicipalitiesBatch] --> R
|
||||
R --> F[pessoas/formActions: Lidera/Aliada viram contactId]
|
||||
M[RelationChipCell: opt-ins commitWithNullOwner + commitGuard] --> P[MunicipalityPortfolioCell: extraFormFields]
|
||||
P --> W[PeopleMunicipalityCell: guard de saída + diálogo de confirmação]
|
||||
W --> X[PeopleCapacityExitDialog: manifest da saída]
|
||||
R --> X
|
||||
F --> PG[page.tsx: matriz de edição C128]
|
||||
W --> PG
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — escritas person-centric por `contactId` + extração dos deltas das actions de domínio (edit the owner)**: `setPersonLeadershipMunicipalitiesRecord`/`setPersonStateDeputyMunicipalitiesRecord` novas em `person.ts`, resolvendo a entidade da pessoa por `contact` dentro da transação (cria se não existe, apaga se esvazia); os deltas municipais puros (`applyLeadershipMunicipalitiesDelta`, `applyStateDeputyMunicipalitiesBatch`) são extraídos das actions B34/B37 e reusados pelos dois call sites (as listas de lideranças/dobradinhas continuam chamando os wrappers existentes com contrato idêntico).
|
||||
- **B — células chamarem as actions de domínio existentes passando `ownerId`**: precisaria de ownerId mesmo sem entidade (id inventado), transações aninhadas (as record functions de domínio abrem transação própria), e o "cria/apaga no último" teria que vazar para fora delas. Rejeitada: contrato quebrado e duplicação da lógica de ciclo de vida em três lugares.
|
||||
- **C — componentes novos `people/*` com máquina própria de commit+diálogo**: twin da máquina de chips. Rejeitada: o repo proíbe.
|
||||
|
||||
**Recomendação: A** — segue o padrão C116 já estabelecido (record functions person-centric em `person.ts`, toggles extraídos como helpers transaction-agnostic — precedente `toggleMunicipalityAdvisorMembership`); a interação nova (guard de saída destrutiva) entra como **opt-in no `RelationChipCell`** (`commitGuard`), defaults inalterados (B34/B36/B37/B156/B157/B159/B169 intactos).
|
||||
|
||||
### Decisões de engenharia travadas
|
||||
|
||||
- **Saída destrutiva (esvaziar a relação) por escopo, não por papel** (gate 2026-08-11): **coordenação/candidato sempre podem encerrar**; **assessor também encerra Lidera/Aliada** quando **todos** os municípios atuais da capacidade estão na carteira dele (o "piso" dele é a visibilidade: chips fora da carteira nem renderizam, então o esvaziamento só ocorre quando a capacidade inteira está na carteira). **Assessora segue coordenação/candidato** (decisão do gate da intenção — criação/remoção de conta staff). O servidor re-checa o escopo completo antes do delete (não só o lote removido) e o diálogo de confirmação precede a destruição; assessor que não cobre a capacidade inteira nunca esvazia — remover os chips visíveis deixa os municípios fora da carteira intactos (remoção normal, sem delete).
|
||||
- **Fim de assessoria: a conta staff é APAGADA** (aceite) — com cascata dos itens autorados (`campaignInvite.createdBy`, `municipalityUpdate.author`, `calendarFeed.createdBy`, `supporterImportBatch.actor` — mesma ordem do `personDelete`, FK NOT NULL) e dos vínculos de assessorado (`leadership.advisors`, `stateDeputy.advisors`, `activity.advisors` — join tables cascateiam no `campaign_user_id`); o diálogo lista conta + autorias + assessorado. Extrair a cascata de conta do `personDelete.ts` para helper compartilhado (edit the owner, não twin).
|
||||
- **Fim de liderança:** confirmação **somente quando houver votos declarados ou convites pendentes** (manifest lista ambos; intenção: "com confirmação se houver votos declarados"); sem eles, remove direto (nada próprio a perder). Ordem do delete: `votePledge` → `campaignInvite` → `leadership` (contrato FK do `personDelete`).
|
||||
- **Fim de dobradinha:** sem confirmação extra (rabbit hole da intenção — "limpeza automática dos vínculos na mesma transação"); o delete da linha limpa os vínculos restantes de `municipality.stateDeputies`/`leadership.stateDeputies` pelos FKs (mesmo comportamento do `personDelete`).
|
||||
- **Criação de conta staff:** `{ name: <nome da ficha>, role: 'advisor', contact: contactId, password: randomBytes(24).base64url }` — **sem e-mail/username** (decisão do gate: "conta criada sem e-mail/username utilizáveis"); `contact` explícito via `overrideAccess: true` justificado (o hook C99 `resolveContactForAccount` criaria ficha órfã quando a pessoa não tem telefone — com `contact` explícito ele não roda). A conta não consegue logar até o provisionamento (mesmo contrato do `createMunicipalityAdvisorRecord`).
|
||||
- **Criação de liderança:** `{ contact: contactId, municipalities: ids }` com `user: currentActor, overrideAccess: false` — `canCreateLeadership` re-checa o escopo do assessor (todos os municípios na carteira); cap `MAX_LEADERSHIP_MUNICIPALITIES` mantido; nunca update com array vazio (o hook `requireAtLeastOneMunicipality` recusaria) — esvaziou, vai direto ao delete.
|
||||
- **Criação de dobradinha:** `{ contact: contactId }` (slug auto do nome; `party` null) com `user` + `overrideAccess: false` (`canCreateStateDeputy` = staff); conflito de slug (dobradinha com mesmo nome em outra ficha) mapeado para mensagem segura; depois o batch municipal (reusa o helper extraído, que re-checa `canUpdateMunicipality` por município).
|
||||
- **FormData das células:** `MunicipalityPortfolioCell` ganha `extraFormFields` (append de `contactId` ao FormData) e `commitWithNullOwner` (o early-return de `ownerId === null` no `RelationChipCell` é o que hoje impede o commit de criar). As formActions Lidera/Aliada de `/pessoas` passam a ler `contactId` (deixam de ler `ownerId`); as listas de domínio não mudam.
|
||||
- **Guarda de saída:** opt-in `commitGuard` no `RelationChipCell`, chamado em `attemptRemove`/`pickHit` **antes** do apply otimista (mesmo ponto dos refusals de floor/cap) — recebe `{ changedIds, assigned, currentIds }` (currentIds = `effectiveIds`, ciente do otimista). `false` aborta sem apply; `true` segue o commit normal. O path de undo-toast (re-adicionar) não passa pela guarda (nunca esvazia). **Fail-closed:** falha ao carregar o manifest aborta o commit com toast de erro (nada é apagado sem confirmação).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Puro (lib, client-safe):**
|
||||
|
||||
- **`src/lib/schemas/personCell.ts`**: `personLeadershipMembershipSchema` / `personStateDeputyMembershipSchema` (`contactId` + `municipalityIds` + `assigned` — mesmo shape do `personAssessoraMembershipSchema`), `personCapacityExitSchema` (`capacity: 'account' | 'leadership'` + `contactId`); novas mensagens seguras: `PERSON_CAPACITY_EXIT_SCOPE_MESSAGE` ("Você só pode encerrar a capacidade quando todos os municípios dela estão na sua carteira."), `PERSON_LEADERSHIP_CREATE_CONFLICT_MESSAGE` (já existe liderança para a ficha), `PERSON_STATE_DEPUTY_CREATE_CONFLICT_MESSAGE` (slug/nome já em uso). `PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE` deixa de ser usada na célula (criação substitui a recusa) — removida das safeMessages, mantida a constante por compatibilidade de testes a ajustar.
|
||||
|
||||
**Server (`person.ts` + formActions + utilities):**
|
||||
|
||||
- **`src/app/(campaign)/campanha/actions/person.ts`** — três record functions novas/estendidas, todas `withPayloadTransaction` + `req`:
|
||||
- `setPersonAssessoraMembershipRecord` (**estendida**): `reloadUnrestrictedActor`; resolve contas por `contact` (limite 2): 0 contas + `assigned` → **cria** a conta (decisão acima) e aplica o toggle; 0 contas + remoção → no-op (nada a remover); 1 conta → toggle existente; 2+ → recusa multi-conta (inalterada). Esvaziou a carteira → **delete da conta** (cascata autorias via helper compartilhado extraído do `personDelete.ts`).
|
||||
- `setPersonLeadershipMunicipalitiesRecord` (**nova**): `reloadStaffActor`; resolve liderança por `contact` (`user`-threaded); sem liderança + `assigned` → cria; sem + remoção → no-op; com + `assigned` → `applyLeadershipMunicipalitiesDelta` (extraído); com + remoção esvaziando → **guarda de escopo do exit** (unrestricted sempre; assessor exige que TODOS os municípios atuais da liderança estejam na carteira — senão recusa com mensagem segura) → delete pledges/invites/leadership em ordem (bypass justificado pela guarda + diálogo). Retorna slugs tocados para revalidação.
|
||||
- `setPersonStateDeputyMunicipalitiesRecord` (**nova**): `reloadStaffActor`; resolve dobradinha por `contact`; sem + `assigned` → cria (conflito de slug mapeado) + batch; sem + remoção → no-op; com + `assigned` → `applyStateDeputyMunicipalitiesBatch` (extraído); com + esvaziando → mesma guarda de escopo do exit (todos os municípios atuais na carteira para assessor) → delete da linha (refs limpam pelos FKs, mesmo contrato do `personDelete`).
|
||||
- `getPersonCapacityExitManifestAction` (**nova**): `reloadStaffActor` + mesma guarda de escopo do exit (fail-closed: assessor que não cobre a capacidade inteira não vê manifest); manifest read-only: `account` → `{ accountName, authored: { inviteCount, updateCount, feedCount }, assessorado: { leadershipNames, deputyNames, activityNames } }` (unrestricted-only — decisão do gate); `leadership` → `{ declaredVoteCount, inviteCount, municipalityNames }`. Sem escrita, sem locks.
|
||||
- Wrappers `setPersonLeadershipMunicipalities` / `setPersonStateDeputyMunicipalities` com revalidação: `/campanha/pessoas` + `/campanha/liderancas` (page + `[id]`) ou `/campanha/dobradinhas` (page + `[id]`) + `/campanha/municipios/<slug>` tocados (+ `/campanha/assessores` page + `[id]` no assessora).
|
||||
- **`src/app/(campaign)/campanha/actions/leadership.ts`**: extrair `applyLeadershipMunicipalitiesDelta(payload, currentActor, req, leadershipId, municipalityIds, assigned)` → `{ next, slugs }` (read `user`-threaded → `nextMunicipalityIdsAfterLeadershipMembership` → `assertMunicipalitiesWithinScope` nos adicionados → update `user`-threaded → slugs). `setLeadershipMunicipalitiesMembershipRecord` passa a chamá-la (contrato externo idêntico).
|
||||
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`**: extrair `applyStateDeputyMunicipalitiesBatch(payload, currentActor, req, stateDeputyId, municipalityIds, assigned)` → `{ slugs }` (corpo atual do batch: locks por município, read/update `user`-threaded). `setStateDeputyMunicipalitiesBatchRecord` passa a chamá-la.
|
||||
- **`src/utilities/people/personDelete.ts`**: extrair `deleteCampaignUserAccount(payload, req, accountID)` (autorias → `payload.delete` por id para rodar hooks) reusado pelo `deletePersonRecord` e pelo fim de assessoria.
|
||||
- **`src/app/(campaign)/campanha/(app)/pessoas/formActions.ts`**: `setPersonLeadershipMunicipalitiesFormAction` / `setPersonStateDeputyMunicipalitiesFormAction` passam a ler `contactId` e delegar às record functions person-centric; `setPersonAssessoraFormAction` inalterada (já é contactId — o bug latente some quando a célula passa a enviar o campo). SafeMessages: `personAssessoraSafeMessages` perde `NO_ACCOUNT`; novas mensagens adicionadas às listas.
|
||||
- **`src/utilities/people/peopleData.ts`**: sem mudança (o VM já expõe `staff`, `leadershipID`, `deputyID`, municipios por capacidade).
|
||||
- **Migration:** nenhuma (sem mudança de schema).
|
||||
|
||||
**UI (Impeccable B — encaixe em tela existente):**
|
||||
|
||||
- **`src/components/campaign/shared/RelationChipCell.tsx`**: dois opt-ins, defaults inalterados: `commitWithNullOwner?: boolean` (skip do early-return `if (ownerId === null) return` quando ligado) e `commitGuard?: (delta: { changedIds: number[]; assigned: boolean; currentIds: number[] }) => Promise<boolean>` (chamado em `attemptRemove`/`pickHit` antes do apply otimista; `false` aborta).
|
||||
- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os dois opt-ins; novo `extraFormFields?: Record<string, string | number>` (append no `buildFormData`, ex. `contactId`).
|
||||
- **`src/components/campaign/people/PeopleCapacityExitDialog.tsx`** (novo): AlertDialog no padrão `DeletePersonButton` (manifest carregado ao abrir, lista verbatim, botão destrutivo com spinner); copy por capacidade ("Encerrar liderança" / "Encerrar assessoria"): liderança → "N votos declarados · M convites pendentes · municípios"; assessoria → "conta de acesso (nome) · autorias · assessorado". Cancelar/fechar aborta.
|
||||
- **`src/components/campaign/people/PeopleMunicipalityCell.tsx`** (estendido): props novas `contactId: number`, `exitMode?: 'account' | 'leadership' | 'stateDeputy'`; passa `extraFormFields={{ contactId }}` + `commitWithNullOwner`; implementa `commitGuard`: esvaziar relação → manifest action (`account`/`leadership`; `stateDeputy` retorna `true` direto — sem diálogo); `account` sempre abre diálogo; `leadership` abre se `declaredVoteCount > 0 || inviteCount > 0`; confirmado → `true`; cancelado/falha → `false`.
|
||||
- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: matriz `buildPeopleEditability` C128 — `canEditAssessora = unrestricted && row.staff.length <= 1` (0 = cria, 1 = edita, 2+ = read-only); `canEditLidera`/`canEditAliada = unrestricted || row.leadershipID/deputyID === null || inCarteira(...)` (pessoa sem a capacidade fica editável para o assessor, com `addableIds` = carteira); **sem `minItems` em nenhuma coluna** (o piso do assessor é a visibilidade — chips fora da carteira não renderizam; o esvaziamento só ocorre com a capacidade inteira na carteira); `exitMode` por coluna. `PeopleMobileCards` intacto.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- **Forma escolhida:** o diálogo de confirmação é a única forma "nova" — lista textual do manifest, precedente `DeletePersonButton` (sem dado analítico; contagens do que será removido). Rejeitadas: tooltip/popover de confirmação (gesto destrutivo merece diálogo modal, precedente da cascata) e checkbox de "ciência" (gasto desnecessário).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server core** (~1/3 appetite): schemas + extrações (`applyLeadershipMunicipalitiesDelta`, `applyStateDeputyMunicipalitiesBatch`, `deleteCampaignUserAccount`) + as três record functions + manifest action + wrappers/revalidação. Int tests: cria-conta-na-primeira-cidade (sem email/username, contact linkado, sem ficha órfã), cria-liderança/dobradinha na primeira cidade, remove-última-cidade (delete + cascata), manifest (liderança com/sem votos, assessora com autorias/assessorado), guardas: assessor esvazia Lidera/Aliada com a capacidade inteira na carteira, assessor com municípios fora da carteira NÃO esvazia (recusa server + manifest negado), assessora continua coordenação/candidato (assessor recusa), multi-conta recusa, assessor fora da carteira recusa, no-op de remoção sem entidade, conflito de slug da dobradinha, undo re-cria (assigned=true após delete).
|
||||
2. **UI machines** (~1/3 appetite): opt-ins no `RelationChipCell`/`MunicipalityPortfolioCell` + `PeopleMunicipalityCell` com `commitGuard` + `PeopleCapacityExitDialog`; testes de componente do guard (aborta sem apply, confirmado aplica, falha aborta) e do early-return `commitWithNullOwner`; regressão visual das listas B34/B37/B156/B157/B159/B169 (defaults intactos).
|
||||
3. **Page + polish** (~1/3 appetite): matriz C128, `extraFormFields`/`exitMode` por coluna, tooltip/aria do diálogo, indicador discreto de foco; **correção do bug latente da célula Assessora** (e2e do commit Assessora agora salva); shape→craft→critique→polish.
|
||||
4. **Gates:** após cada slice `pnpm exec tsc --noEmit` + testes focados; ao final `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (estender `campaignPeople.e2e.spec.ts` com um fluxo de ciclo de vida: criar conta na primeira cidade + diálogo de encerramento), `pnpm build` local. Entrada no `docs/CHANGELOG-AGENTS.md`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Assessor encerrar a **Assessora** (conta staff) — fora: decisão do gate da intenção (coordenação/candidato).
|
||||
- Remover conta com carteira vazia pré-existente sem passar pela célula (fica no fluxo "Apagar pessoa", como decidido).
|
||||
- Busca/autocomplete de Contatos sem capacidade (v1 sem, decisão da intenção).
|
||||
- Contenteditable, tree-view de território, edição em lote — fora (herdado do C116).
|
||||
- Migration de schema — nenhuma.
|
||||
- Não mexer nas listas de domínio além dos helpers extraídos (contrato externo idêntico).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Transações aninhadas** (person action chamando record function de domínio): só helpers transaction-agnostic (`req`-threaded) dentro da transação; as extrações mantêm o contrato dos wrappers existentes.
|
||||
- **Célula Assessora quebrada hoje (bug latente C116)**: o refactor envia `contactId`; int test do formAction (não só da record function) cobre o commit completo.
|
||||
- **Hook `requireAtLeastOneMunicipality` vs esvaziamento**: o esvaziamento nunca faz update com array vazio — vai direto ao delete dentro da mesma transação.
|
||||
- **Cascata da conta staff esquecendo autorias (FK NOT NULL)**: helper compartilhado extraído do `personDelete` (ordem já validada em prod); int test do delete com autorias presentes.
|
||||
- **Undo-toast após delete de entidade**: "Desfazer" re-adiciona (assigned=true) e a criação-no-add re-cria a entidade — int test do ciclo undo.
|
||||
- **Regressão das células compartilhadas**: opt-ins com defaults; testes unit/int existentes das células de relação rodam intactos na Fase 2.
|
||||
- **Manifest divergindo do delete (race)**: o manifest é preview, o delete re-enumera dentro da transação (mesmo contrato do `personDelete`); guard fail-closed (erro de manifest aborta).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (células editáveis para toda pessoa visível; primeira cidade cria; última remove; confirmação destrutiva lista o que será removido; capacidades independentes; escopo por ator preservado)
|
||||
- [ ] Sem migration; Local API com `user`/`overrideAccess: false`; bypasses justificados (criação de conta com `contact` explícito, deletes destrutivos) após a checagem de escopo; transações com `req` em toda escrita multi-collection
|
||||
- [ ] Saída destrutiva por escopo: assessor encerra Lidera/Aliada só com a capacidade inteira na carteira (servidor re-checa); Assessora continua coordenação/candidato; multi-conta do Assessora recusa
|
||||
- [ ] Mensagens seguras allowlisted; copy pt-BR; identificadores em inglês
|
||||
- [ ] Bug latente C116 (contactId nunca no FormData da Assessora) corrigido e coberto por teste
|
||||
- [ ] Testes previstos: int (ciclo de vida das três capacidades, manifest, guardas de acesso, undo, cascatas) + componente (guard) + e2e (um fluxo completo)
|
||||
- [ ] Entrada curta no `docs/CHANGELOG-AGENTS.md`
|
||||
|
||||
**Self-score decision-quality: 5/5** — decisões caras (person-centric vs ownerId, extração de helpers vs twin, saída destrutiva por escopo de carteira vs papel, criação de conta sem credenciais, guard no `RelationChipCell` vs interceptação externa) têm alternativas rejeitadas com justificativa; appetite bounded por fases; rabbit holes nomeados; reuso máximo das máquinas existentes; o aceite da intenção não foi alterado.
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
setPersonAdvisorMembership,
|
||||
setPersonAssessoraMembership,
|
||||
setPersonLeadershipMunicipalities,
|
||||
setPersonStateDeputyMunicipalities,
|
||||
updatePersonContact,
|
||||
} from '@/app/(campaign)/campanha/actions/person'
|
||||
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedRelationshipFormValues,
|
||||
|
|
@ -15,24 +15,21 @@ import {
|
|||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
|
||||
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
|
||||
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
} from '@/lib/schemas/personCell'
|
||||
import {
|
||||
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -46,7 +43,6 @@ const personContactSafeMessages = [
|
|||
|
||||
const personAssessoraSafeMessages = [
|
||||
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
|
||||
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
] as const
|
||||
|
||||
|
|
@ -55,13 +51,21 @@ const personAssessoradoSafeMessages = [
|
|||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
] as const
|
||||
|
||||
const leadershipMunicipalitiesSafeMessages = [
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
const personLeadershipSafeMessages = [
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
] as const
|
||||
|
||||
const personStateDeputySafeMessages = [
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
] as const
|
||||
|
||||
/** Per-field Contact edit for the people list cells (C116). */
|
||||
export const updatePersonContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
@ -148,41 +152,38 @@ export const setPersonAssessoradoFormAction = async (
|
|||
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
|
||||
})
|
||||
|
||||
/** Lidera column (C116) — reuses the leadership municipalities write. */
|
||||
/** Lidera column (C128) — person-centric lifecycle: creates/removes the leadership. */
|
||||
export const setPersonLeadershipMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setLeadershipMunicipalitiesMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
await setPersonLeadershipMunicipalities({
|
||||
contactId: requiredRelationshipFormValue(formData, 'contactId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages: leadershipMunicipalitiesSafeMessages,
|
||||
safeMessages: personLeadershipSafeMessages,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
||||
/** Aliada em column (C116) — reuses the dobradinha municipalities write. */
|
||||
/** Aliada em column (C128) — person-centric lifecycle: creates/removes the dobradinha. */
|
||||
export const setPersonStateDeputyMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setStateDeputyMunicipalitiesBatch({
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
await setPersonStateDeputyMunicipalities({
|
||||
contactId: requiredRelationshipFormValue(formData, 'contactId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages: [
|
||||
...STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
] as const,
|
||||
safeMessages: personStateDeputySafeMessages,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -121,15 +121,17 @@ const PeopleListEmptyState = ({
|
|||
)
|
||||
|
||||
/**
|
||||
* C116 editability matrix by actor. The advisor rule mirrors each owner
|
||||
* C116/C128 editability matrix by actor. The advisor rule mirrors each owner
|
||||
* collection's access: Lidera/Aliada em follow
|
||||
* `canManageLeadership`/`canReadStateDeputy` (the row's own municipalities
|
||||
* must cross the carteira — the row may be visible via another capacity);
|
||||
* Assessora and Assessorado are unrestricted-only
|
||||
* (`canAssignMunicipalityAdvisors` / B156), and Assessora needs exactly ONE
|
||||
* staff account for the batch write to have a target. The ficha text cells
|
||||
* stay always-editable and the server enforces the scope ("you edit what you
|
||||
* see") with a per-cell error.
|
||||
* must cross the carteira — the row may be visible via another capacity), and
|
||||
* C128 opens the cells for people WITHOUT the capacity yet (the first
|
||||
* municipality creates the entity; the advisor's `addableIds` keeps additions
|
||||
* inside his carteira). Assessora and Assessorado are unrestricted-only
|
||||
* (`canAssignMunicipalityAdvisors` / B156), and Assessora needs at most ONE
|
||||
* staff account for the batch write to have a target (zero = the C128
|
||||
* create). The ficha text cells stay always-editable and the server enforces
|
||||
* the scope ("you edit what you see") with a per-cell error.
|
||||
*/
|
||||
type PeopleEditability = {
|
||||
administered: ReadonlySet<number> | null
|
||||
|
|
@ -148,11 +150,14 @@ const buildPeopleEditability = (
|
|||
administered !== null && ids.some((id) => administered.has(id))
|
||||
return {
|
||||
administered,
|
||||
// C128: a person without the capacity yet is editable (the first
|
||||
// municipality creates the entity); with one, the row's own municipalities
|
||||
// must cross the carteira (the chips outside it do not render anyway).
|
||||
canEditLidera: (row) =>
|
||||
row.leadershipID !== null && (unrestricted || inCarteira(row.leadershipMunicipalityIDs)),
|
||||
unrestricted || row.leadershipID === null || inCarteira(row.leadershipMunicipalityIDs),
|
||||
canEditAliada: (row) =>
|
||||
row.deputyID !== null && (unrestricted || inCarteira(row.deputyMunicipalityIDs)),
|
||||
canEditAssessora: (row) => unrestricted && row.staff.length === 1,
|
||||
unrestricted || row.deputyID === null || inCarteira(row.deputyMunicipalityIDs),
|
||||
canEditAssessora: (row) => unrestricted && row.staff.length <= 1,
|
||||
canEditAssessorado: unrestricted,
|
||||
}
|
||||
}
|
||||
|
|
@ -244,6 +249,8 @@ const peopleColumns = ({
|
|||
<PeopleMunicipalityCell
|
||||
ownerId={editability.canEditAssessora(row) ? (row.staff[0]?.id ?? null) : null}
|
||||
ownerName={row.name}
|
||||
contactId={row.contactID}
|
||||
exitMode="account"
|
||||
municipalityIds={row.assessoraMunicipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
commitAction={setPersonAssessoraFormAction}
|
||||
|
|
@ -262,10 +269,11 @@ const peopleColumns = ({
|
|||
<PeopleMunicipalityCell
|
||||
ownerId={row.leadershipID}
|
||||
ownerName={row.name}
|
||||
contactId={row.contactID}
|
||||
exitMode="leadership"
|
||||
municipalityIds={row.leadershipMunicipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
{...(editability.administered ? { addableIds: editability.administered } : {})}
|
||||
minItems={1}
|
||||
commitAction={setPersonLeadershipMunicipalitiesFormAction}
|
||||
drawerTitle="Municípios liderados"
|
||||
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
|
||||
|
|
@ -282,6 +290,8 @@ const peopleColumns = ({
|
|||
<PeopleMunicipalityCell
|
||||
ownerId={row.deputyID}
|
||||
ownerName={row.name}
|
||||
contactId={row.contactID}
|
||||
exitMode="stateDeputy"
|
||||
municipalityIds={row.deputyMunicipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
{...(editability.administered ? { addableIds: editability.administered } : {})}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
type LeadershipWizardUpdateInput,
|
||||
type MunicipalityLeadershipCreateInput,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
|
|
@ -70,16 +70,7 @@ const assertMunicipalitiesWithinScope = async (
|
|||
}
|
||||
}
|
||||
|
||||
const isUniqueLeadershipConflict = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/leadership_contact|duplicate key/i.test(message)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(error instanceof Error) || error.name !== 'ValidationError') return false
|
||||
|
||||
return /contact(?:_id)?/i.test(JSON.stringify(error))
|
||||
}
|
||||
import { isUniqueLeadershipConflict } from '@/utilities/leadershipConflict'
|
||||
|
||||
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
|
||||
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phones'> & {
|
||||
|
|
@ -647,6 +638,66 @@ const revalidateLeadershipMunicipalityPaths = (
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C128 — the B34 municipalities delta core, transaction-agnostic: given the
|
||||
* leadership's CURRENT municipalities (read under the caller's advisory lock),
|
||||
* computes the membership change, scope-checks the added ids and writes it.
|
||||
* Shared by the `/campanha/liderancas` chip cell (B34) and the person
|
||||
* lifecycle (`setPersonLeadershipMunicipalitiesRecord`) so the two surfaces
|
||||
* can never disagree on the delta contract.
|
||||
*/
|
||||
export const applyLeadershipMunicipalitiesDelta = async (
|
||||
payload: Payload,
|
||||
currentActor: CampaignUser,
|
||||
req: PayloadTransactionRequest,
|
||||
leadershipId: number,
|
||||
currentMunicipalityIDs: readonly number[],
|
||||
municipalityIds: readonly number[],
|
||||
assigned: boolean,
|
||||
): Promise<{ leadership: Leadership | null; slugs: string[] }> => {
|
||||
const change = nextMunicipalityIdsAfterLeadershipMembership(
|
||||
currentMunicipalityIDs,
|
||||
municipalityIds,
|
||||
assigned,
|
||||
)
|
||||
|
||||
// No-op: nothing to write, and nothing for the caller to revalidate.
|
||||
if (change === null) {
|
||||
return { leadership: null, slugs: [] }
|
||||
}
|
||||
|
||||
// Only the ids the write actually adds: an advisor must stay able to drop a
|
||||
// link outside their scope, and re-deriving `added` here could disagree.
|
||||
if (change.added.length > 0) {
|
||||
await assertMunicipalitiesWithinScope(payload, currentActor, change.added, req)
|
||||
}
|
||||
|
||||
// Intentional admin bypass: only used to resolve the slugs of the touched
|
||||
// municipalities for a targeted revalidate; existence is otherwise
|
||||
// enforced by Payload's relationship validation on `update`.
|
||||
const touched = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { in: change.changed } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: 'leadership',
|
||||
id: leadershipId,
|
||||
data: { municipalities: change.next },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
return { leadership: updated, slugs: touched.docs.map((doc) => doc.slug) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta write for the "Municípios" column of `/campanha/liderancas` (B34) —
|
||||
* adds or removes a set of municipalities (one chip, or a whole território/ZE)
|
||||
|
|
@ -677,47 +728,20 @@ export const setLeadershipMunicipalitiesMembershipRecord = async (
|
|||
'municipalities',
|
||||
)
|
||||
|
||||
const change = nextMunicipalityIdsAfterLeadershipMembership(
|
||||
const delta = await applyLeadershipMunicipalitiesDelta(
|
||||
payload,
|
||||
currentActor,
|
||||
req,
|
||||
leadershipId,
|
||||
uniqueRelationshipIds(current.municipalities),
|
||||
municipalityIds,
|
||||
assigned,
|
||||
)
|
||||
|
||||
// No-op: nothing to write, and nothing for the caller to revalidate.
|
||||
if (change === null) {
|
||||
return { leadership: current, municipalitySlugs: [] }
|
||||
return {
|
||||
leadership: delta.leadership ?? current,
|
||||
municipalitySlugs: delta.slugs,
|
||||
}
|
||||
|
||||
// Only the ids the write actually adds: an advisor must stay able to drop a
|
||||
// link outside their scope, and re-deriving `added` here could disagree.
|
||||
if (change.added.length > 0) {
|
||||
await assertMunicipalitiesWithinScope(payload, currentActor, change.added, req)
|
||||
}
|
||||
|
||||
// Intentional admin bypass: only used to resolve the slugs of the touched
|
||||
// municipalities for a targeted revalidate; existence is otherwise
|
||||
// enforced by Payload's relationship validation on `update`.
|
||||
const touched = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { in: change.changed } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: 'leadership',
|
||||
id: leadershipId,
|
||||
data: { municipalities: change.next },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
return { leadership: updated, municipalitySlugs: touched.docs.map((doc) => doc.slug) }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar os municípios da liderança.' },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@ import {
|
|||
} from '@/lib/engagementLevel'
|
||||
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
|
||||
import { uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { stubCampaignUserEmailFor } from '@/lib/schemas/advisor'
|
||||
import {
|
||||
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
|
||||
MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE,
|
||||
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
|
||||
municipalityAdvisorCreateSchema,
|
||||
municipalityAdvisorMembershipSchema,
|
||||
|
|
@ -38,6 +36,7 @@ import {
|
|||
reloadStaffActor,
|
||||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import { nextFreeStubCampaignEmail } from '@/utilities/campaignStubEmail'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
|
@ -413,6 +412,7 @@ export const createMunicipalityAdvisorRecord = async (
|
|||
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`])
|
||||
|
||||
const email = await nextFreeStubCampaignEmail(payload, req, name)
|
||||
|
||||
// Same auth-create contract as `createAdvisorRecord` (B19): the create
|
||||
// access (`canManageCampaignUsers`) re-checks the unrestricted role
|
||||
// through the `user` threaded into this transaction's `req`.
|
||||
|
|
@ -474,33 +474,3 @@ export const createMunicipalityAdvisor = async (input: MunicipalityAdvisorCreate
|
|||
const { payload, actor } = await getCampaignActionContext()
|
||||
return createMunicipalityAdvisorRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next free `<slug>@criado.invalid` stub: bounded exact-match probes on the
|
||||
* unique `email` index (no LIKE semantics). Collisions only happen when two
|
||||
* names slugify identically, so the first probe usually succeeds. The read
|
||||
* bypasses access control on purpose: the transaction already verified the
|
||||
* unrestricted role, and the probe only looks at placeholder e-mails.
|
||||
*/
|
||||
const nextFreeStubCampaignEmail = async (
|
||||
payload: Payload,
|
||||
req: PayloadTransactionRequest,
|
||||
name: string,
|
||||
): Promise<string> => {
|
||||
for (let occurrence = 1; occurrence <= 50; occurrence += 1) {
|
||||
const candidate = stubCampaignUserEmailFor(name, occurrence)
|
||||
const existing = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { email: { equals: candidate } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { email: true },
|
||||
// Intentional admin bypass: unrestricted role verified above in the
|
||||
// transaction; the probe only reads placeholder e-mails.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (existing.totalDocs === 0) return candidate
|
||||
}
|
||||
throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,95 @@
|
|||
'use server'
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import { applyLeadershipMunicipalitiesDelta } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { toggleMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import { applyStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import { uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
|
||||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
MAX_LEADERSHIP_MUNICIPALITIES,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
|
||||
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
personAdvisorMembershipSchema,
|
||||
personAssessoraMembershipSchema,
|
||||
personCapacityExitSchema,
|
||||
personLeadershipMembershipSchema,
|
||||
personStateDeputyMembershipSchema,
|
||||
type PersonAdvisorMembershipInput,
|
||||
type PersonAssessoraMembershipInput,
|
||||
type PersonLeadershipMembershipInput,
|
||||
type PersonStateDeputyMembershipInput,
|
||||
} from '@/lib/schemas/personCell'
|
||||
import {
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
personDeleteInputSchema,
|
||||
} from '@/lib/schemas/personDelete'
|
||||
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadStaffActor,
|
||||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import { mapStaffEntityConflict } from '@/utilities/campaignEntityActions'
|
||||
import { nextFreeStubCampaignEmail } from '@/utilities/campaignStubEmail'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { isUniqueLeadershipConflict } from '@/utilities/leadershipConflict'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
} from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
assertPersonCapacityExitScope,
|
||||
loadPersonCapacityExitManifest,
|
||||
} from '@/utilities/people/personCapacityExit'
|
||||
import {
|
||||
deleteCampaignUserAccount,
|
||||
deletePersonRecord,
|
||||
loadPersonDeleteManifest,
|
||||
} from '@/utilities/people/personDelete'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
|
||||
import { stateDeputyPolicy } from '@/utilities/stateDeputyConflict'
|
||||
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/** C128 — slugs of the touched municipalities, for targeted revalidation. */
|
||||
const municipalitySlugsOf = async (
|
||||
payload: Payload,
|
||||
req: PayloadTransactionRequest,
|
||||
municipalityIDs: readonly number[],
|
||||
): Promise<string[]> => {
|
||||
if (municipalityIDs.length === 0) return []
|
||||
const touched = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { in: [...municipalityIDs] } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { slug: true },
|
||||
// Intentional admin bypass: only resolves slugs for revalidation; the
|
||||
// caller's scope check authorizes the write this read serves.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return touched.docs.map((doc) => doc.slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only
|
||||
* (`reloadUnrestrictedActor`): the cascade is transversal, wider than any
|
||||
|
|
@ -178,12 +230,18 @@ export const updatePersonContact = async (input: ContactFieldUpdateInput) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* C116 — the Assessora column of a person: toggles the person's staff account
|
||||
* on `municipality.advisors` for a batch of municipalities (one territory / ZE
|
||||
* chip or a "Salvador (19)" group), in ONE transaction. Unrestricted-only
|
||||
* (`canAssignMunicipalityAdvisors`), and the person must have EXACTLY ONE
|
||||
* staff account — with more, "which account" is ambiguous, so the server
|
||||
* refuses with a safe message (the cell renders read-only for those rows).
|
||||
* C116/C128 — the Assessora column of a person: toggles the person's staff
|
||||
* account on `municipality.advisors` for a batch of municipalities (one
|
||||
* territory / ZE chip or a "Salvador (19)" group), in ONE transaction.
|
||||
* Unrestricted-only (`canAssignMunicipalityAdvisors`).
|
||||
*
|
||||
* C128 lifecycle: with NO account, adding the first municipality CREATES the
|
||||
* staff account (role `advisor`, linked to the person's ficha, random
|
||||
* unshared password — no usable e-mail/username, login is provisioned later);
|
||||
* removing the LAST municipality deletes the account (the destructive exit,
|
||||
* confirmed by the dialog that listed the manifest). With more than one
|
||||
* account, "which account" is ambiguous, so the server refuses with a safe
|
||||
* message (the cell renders read-only for those rows).
|
||||
*/
|
||||
export const setPersonAssessoraMembershipRecord = async (
|
||||
payload: Payload,
|
||||
|
|
@ -197,6 +255,14 @@ export const setPersonAssessoraMembershipRecord = async (
|
|||
async ({ req }) => {
|
||||
await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, req)
|
||||
|
||||
// Serializes every Assessora write of the SAME person: the destructive
|
||||
// exit (last municipality) decides "did the carteira empty?" from an
|
||||
// enumeration, and two concurrent removals of distinct chips would each
|
||||
// see a non-empty remainder and leave a zombie account with an empty
|
||||
// carteira — invisible to the lifecycle. The lock makes the
|
||||
// enumerate→decide→toggle/delete sequence atomic per person.
|
||||
await acquireTextAdvisoryLocks(payload, req, [`person-assessora:${contactId}`])
|
||||
|
||||
const accounts = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { contact: { equals: contactId } },
|
||||
|
|
@ -209,13 +275,75 @@ export const setPersonAssessoraMembershipRecord = async (
|
|||
req,
|
||||
})
|
||||
|
||||
if (accounts.docs.length === 0) throw new Error(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE)
|
||||
if (accounts.docs.length > 1) throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
|
||||
const accountId = accounts.docs[0].id
|
||||
|
||||
let accountId: number
|
||||
if (accounts.docs.length === 1) {
|
||||
accountId = Number(accounts.docs[0].id)
|
||||
} else if (assigned) {
|
||||
// C128 — the first municipality creates the account. The explicit
|
||||
// `contact` (justified bypass) keeps the C99 `resolveContactForAccount`
|
||||
// hook from minting an orphan ficha when the person has no phone; the
|
||||
// random password is never shared, so the account cannot log in until
|
||||
// a coordinator provisions credentials (same contract as B154).
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: contactId,
|
||||
depth: 0,
|
||||
select: { name: true },
|
||||
// Intentional bypass: the unrestricted gate above is the
|
||||
// authorization; this read only resolves the account name.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const email = await nextFreeStubCampaignEmail(payload, req, contact.name ?? 'Contato')
|
||||
const created = await payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: hookFilledCreateData<'campaignUser'>({
|
||||
name: contact.name ?? 'Contato',
|
||||
role: 'advisor',
|
||||
contact: contactId,
|
||||
// `@criado.invalid` placeholder — no usable e-mail (never receives
|
||||
// mail, cannot reset the password with it); the random password is
|
||||
// never shared, so the account cannot log in until a coordinator
|
||||
// provisions credentials (same contract as B154).
|
||||
email,
|
||||
password: randomBytes(24).toString('base64url'),
|
||||
}),
|
||||
depth: 0,
|
||||
// Intentional bypass: the unrestricted gate above is the
|
||||
// authorization; the `contact` field is system-gated and the C99
|
||||
// identity hooks would otherwise diverge from the explicit ficha.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
accountId = created.id
|
||||
} else {
|
||||
// Removal on a person with no account: nothing to remove.
|
||||
return { accountId: null, slugs: [] }
|
||||
}
|
||||
|
||||
// Before toggling, decide the destructive exit: a removal that would
|
||||
// empty the carteira deletes the account (with its authored rows and
|
||||
// assessorado links) — the confirmation dialog listed the manifest.
|
||||
if (!assigned) {
|
||||
// Canonical "advisors contains account" lookup, same helper the
|
||||
// advisor scope uses everywhere else.
|
||||
const currentMunicipalityIDs = await getAdvisorMunicipalityIds(payload, accountId, req)
|
||||
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
|
||||
if (next.length === 0) {
|
||||
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
|
||||
await deleteCampaignUserAccount(payload, req, accountId)
|
||||
return { accountId: null, slugs }
|
||||
}
|
||||
}
|
||||
|
||||
for (const municipalityId of municipalityIds) {
|
||||
await toggleMunicipalityAdvisorMembership(payload, req, municipalityId, accountId, assigned)
|
||||
}
|
||||
|
||||
const slugs = await municipalitySlugsOf(payload, req, municipalityIds)
|
||||
return { accountId, slugs }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização da carteira.' },
|
||||
)
|
||||
|
|
@ -223,12 +351,310 @@ export const setPersonAssessoraMembershipRecord = async (
|
|||
|
||||
export const setPersonAssessoraMembership = async (input: PersonAssessoraMembershipInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await setPersonAssessoraMembershipRecord(payload, actor, input)
|
||||
const { accountId, slugs } = await setPersonAssessoraMembershipRecord(payload, actor, input)
|
||||
revalidatePath('/campanha/pessoas', 'page')
|
||||
revalidatePath('/campanha/assessores', 'page')
|
||||
if (accountId !== null) revalidatePath(`/campanha/assessores/${accountId}`, 'page')
|
||||
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
|
||||
revalidateMunicipalityListPaths({ scope: 'list' })
|
||||
}
|
||||
|
||||
/**
|
||||
* C128 — the Lidera column of a person: person-centric lifecycle write. The
|
||||
* first added municipality CREATES the leadership (contact + municipalities,
|
||||
* under `canCreateLeadership` — an advisor may only create within his
|
||||
* carteira); removing the last municipality DELETES the leadership with its
|
||||
* vote pledges and invites (destructive exit, scope-guarded: an advisor needs
|
||||
* EVERY current municipality in his carteira, and the confirmation dialog
|
||||
* listed the manifest). The cap/floor contracts of the leadership collection
|
||||
* stay enforced — the exit goes straight to the delete, never an empty
|
||||
* update (`requireAtLeastOneMunicipality` would reject it).
|
||||
*/
|
||||
export const setPersonLeadershipMunicipalitiesRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: PersonLeadershipMembershipInput,
|
||||
) => {
|
||||
const { contactId, municipalityIds, assigned } = personLeadershipMembershipSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
|
||||
|
||||
const leaderships = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { id: true, municipalities: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const leadership = leaderships.docs[0] as Leadership | undefined
|
||||
|
||||
if (!leadership) {
|
||||
if (!assigned) return { leadershipID: null, slugs: [] }
|
||||
if (municipalityIds.length > MAX_LEADERSHIP_MUNICIPALITIES) {
|
||||
throw new Error(LEADERSHIP_MUNICIPALITY_CAP_MESSAGE)
|
||||
}
|
||||
try {
|
||||
// `canCreateLeadership` re-checks the advisor's carteira through the
|
||||
// user-threaded create (every requested município must be in scope).
|
||||
const created = await payload.create({
|
||||
collection: 'leadership',
|
||||
data: hookFilledCreateData<'leadership'>({
|
||||
contact: contactId,
|
||||
municipalities: municipalityIds,
|
||||
}),
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
const slugs = await municipalitySlugsOf(payload, req, municipalityIds)
|
||||
return { leadershipID: created.id, slugs }
|
||||
} catch (error) {
|
||||
if (isUniqueLeadershipConflict(error)) {
|
||||
throw new Error(LEADERSHIP_DUPLICATE_MESSAGE)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// The advisory lock guards the delta read AND the exit enumeration —
|
||||
// same key the B34 chip cell holds for the same relation.
|
||||
await acquireTextAdvisoryLocks(payload, req, [`leadership-municipalities:${leadership.id}`])
|
||||
const fresh = await payload.findByID({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
depth: 0,
|
||||
select: { municipalities: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
const currentMunicipalityIDs = uniqueRelationshipIds(fresh.municipalities)
|
||||
|
||||
if (!assigned) {
|
||||
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
|
||||
if (next.length === 0) {
|
||||
// Destructive exit: the dialog already listed what this removes
|
||||
// (declared votes, invites); the scope guard is the server-side
|
||||
// re-check. Intentional admin bypasses: the exits delete by id
|
||||
// (hooks) after the scope guard — same order as `personDelete.ts`.
|
||||
await assertPersonCapacityExitScope(payload, currentActor, req, currentMunicipalityIDs)
|
||||
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
|
||||
await payload.delete({
|
||||
collection: 'votePledge',
|
||||
where: { leadership: { equals: leadership.id } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'campaignInvite',
|
||||
where: { leadership: { equals: leadership.id } },
|
||||
depth: 0,
|
||||
// Intentional admin bypass: same exit guard as the pledge delete.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
depth: 0,
|
||||
// Intentional admin bypass: same exit guard as the pledge delete.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return { leadershipID: null, slugs }
|
||||
}
|
||||
}
|
||||
|
||||
const delta = await applyLeadershipMunicipalitiesDelta(
|
||||
payload,
|
||||
currentActor,
|
||||
req,
|
||||
leadership.id,
|
||||
currentMunicipalityIDs,
|
||||
municipalityIds,
|
||||
assigned,
|
||||
)
|
||||
return { leadershipID: delta.leadership ? leadership.id : null, slugs: delta.slugs }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização da liderança.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setPersonLeadershipMunicipalities = async (input: PersonLeadershipMembershipInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const { leadershipID, slugs } = await setPersonLeadershipMunicipalitiesRecord(
|
||||
payload,
|
||||
actor,
|
||||
input,
|
||||
)
|
||||
revalidatePath('/campanha/pessoas', 'page')
|
||||
revalidatePath('/campanha/liderancas', 'page')
|
||||
if (leadershipID !== null) revalidatePath(`/campanha/liderancas/${leadershipID}`, 'page')
|
||||
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
|
||||
}
|
||||
|
||||
/**
|
||||
* C128 — the Aliada em column of a person: person-centric lifecycle write. The
|
||||
* first added municipality CREATES the dobradinha (contact, auto-slug from the
|
||||
* ficha name — a name already in use maps to the safe conflict message);
|
||||
* removing the last municipality DELETES the dobradinha (destructive exit,
|
||||
* scope-guarded as the leadership one). No extra confirmation for the exit:
|
||||
* the dobradinha carries no campaign data of its own beyond the row, so the
|
||||
* vínculo cleanup is automatic (rabbit hole decision of the intention plan).
|
||||
*/
|
||||
export const setPersonStateDeputyMunicipalitiesRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: PersonStateDeputyMembershipInput,
|
||||
) => {
|
||||
const { contactId, municipalityIds, assigned } = personStateDeputyMembershipSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
|
||||
|
||||
// Same per-person serialization as the Assessora lifecycle: the
|
||||
// destructive exit (last municipality) decides from an enumeration, and
|
||||
// concurrent removals of distinct chips would leave a zombie dobradinha.
|
||||
await acquireTextAdvisoryLocks(payload, req, [`person-state-deputy:${contactId}`])
|
||||
|
||||
const deputies = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { contact: { equals: contactId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const deputy = deputies.docs[0] as StateDeputy | undefined
|
||||
|
||||
if (!deputy) {
|
||||
if (!assigned) return { stateDeputyID: null, slugs: [] }
|
||||
// The name invariant (advisory-locked) keeps the historical
|
||||
// unique-name rule at the StateDeputy boundary — same as the B157
|
||||
// inline create.
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: contactId,
|
||||
depth: 0,
|
||||
select: { name: true },
|
||||
// Intentional admin bypass: the staff gate above is the
|
||||
// authorization; this read only resolves the dobradinha name.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await assertStateDeputyNameAvailable(payload, req, contact.name ?? 'Contato')
|
||||
let created
|
||||
try {
|
||||
created = await payload.create({
|
||||
collection: 'stateDeputy',
|
||||
data: hookFilledCreateData<'stateDeputy'>({ contact: contactId }),
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
} catch (error) {
|
||||
const conflict = mapStaffEntityConflict(error, stateDeputyPolicy)
|
||||
if (conflict) throw conflict
|
||||
throw error
|
||||
}
|
||||
const batch = await applyStateDeputyMunicipalitiesBatch(
|
||||
payload,
|
||||
currentActor,
|
||||
req,
|
||||
created.id,
|
||||
municipalityIds,
|
||||
true,
|
||||
)
|
||||
return { stateDeputyID: created.id, slugs: batch.slugs }
|
||||
}
|
||||
|
||||
if (!assigned) {
|
||||
const byDeputy = await municipalityIdsByStateDeputyIds(payload, [deputy.id])
|
||||
const currentMunicipalityIDs = byDeputy.get(deputy.id) ?? []
|
||||
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
|
||||
if (next.length === 0) {
|
||||
// Destructive exit — the vínculos clean up via the FKs (set-null on
|
||||
// `municipality.stateDeputies` / `leadership.stateDeputies`), same
|
||||
// contract as `personDelete.ts`. Intentional admin bypass: the
|
||||
// delete runs by id (hooks) after the exit scope guard.
|
||||
await assertPersonCapacityExitScope(payload, currentActor, req, currentMunicipalityIDs)
|
||||
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
|
||||
await payload.delete({
|
||||
collection: 'stateDeputy',
|
||||
id: deputy.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return { stateDeputyID: null, slugs }
|
||||
}
|
||||
}
|
||||
|
||||
const batch = await applyStateDeputyMunicipalitiesBatch(
|
||||
payload,
|
||||
currentActor,
|
||||
req,
|
||||
deputy.id,
|
||||
municipalityIds,
|
||||
assigned,
|
||||
)
|
||||
return {
|
||||
stateDeputyID: batch.slugs.length > 0 ? deputy.id : null,
|
||||
slugs: batch.slugs,
|
||||
}
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização da dobradinha.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setPersonStateDeputyMunicipalities = async (
|
||||
input: PersonStateDeputyMembershipInput,
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const { stateDeputyID, slugs } = await setPersonStateDeputyMunicipalitiesRecord(
|
||||
payload,
|
||||
actor,
|
||||
input,
|
||||
)
|
||||
revalidatePath('/campanha/pessoas', 'page')
|
||||
revalidatePath('/campanha/dobradinhas', 'page')
|
||||
if (stateDeputyID !== null) revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
|
||||
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
|
||||
// Same surface as the B37 twin: the list and the B147 v2 render the
|
||||
// dobradinha chips per município.
|
||||
revalidateMunicipalityListPaths({ scope: 'list' })
|
||||
}
|
||||
|
||||
/**
|
||||
* C128 — read-only preview for the destructive-exit confirmation dialog
|
||||
* (`PeopleCapacityExitDialog`): what the exit removes, verbatim. The same
|
||||
* scope guards the exit write runs inside its transaction — the dialog can
|
||||
* never describe a destruction the write would refuse. The actor is reloaded
|
||||
* fresh (same discipline as every record function): a downgraded or removed
|
||||
* role must not read the manifest the write would refuse.
|
||||
*/
|
||||
export const getPersonCapacityExitManifestAction = async (input: unknown) => {
|
||||
const data = personCapacityExitSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const currentActor =
|
||||
data.capacity === 'account'
|
||||
? await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE)
|
||||
: await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE)
|
||||
return loadPersonCapacityExitManifest(payload, currentActor, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — the Assessorado column: the advisor delta is applied to EVERY entity
|
||||
* of the person (leadership and/or dobradinha) in one transaction — the column
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
122
src/components/campaign/people/PeopleCapacityExitDialog.tsx
Normal file
122
src/components/campaign/people/PeopleCapacityExitDialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/AlertDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { PersonCapacityExitManifest } from '@/utilities/people/personCapacityExit'
|
||||
|
||||
type PeopleCapacityExitDialogProps = {
|
||||
open: boolean
|
||||
personName: string
|
||||
manifest: PersonCapacityExitManifest
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const plural = (count: number, singular: string, pluralForm?: string): string =>
|
||||
`${count} ${count === 1 ? singular : (pluralForm ?? `${singular}s`)}`
|
||||
|
||||
/**
|
||||
* C128 — the destructive-exit confirmation of the people list: when the LAST
|
||||
* municipality of a capacity leaves the row, the entity (staff account or
|
||||
* leadership) dies server-side. This dialog lists — verbatim from the
|
||||
* read-only manifest — everything the transactional exit removes, and only
|
||||
* then lets the guard resolve the pending commit (same pattern as
|
||||
* `DeletePersonButton`; a cancel resolves the guard to `false` and nothing is
|
||||
* touched).
|
||||
*/
|
||||
export const PeopleCapacityExitDialog = ({
|
||||
open,
|
||||
personName,
|
||||
manifest,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PeopleCapacityExitDialogProps) => {
|
||||
const isLeadership = manifest.capacity === 'leadership'
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{isLeadership ? 'Encerrar liderança' : 'Encerrar assessoria'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. Será removida a capacidade de{' '}
|
||||
<span className="font-medium text-foreground">{personName}</span>:
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{isLeadership ? (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Liderança (municípios: {manifest.municipalityNames.join(' · ') || '—'})</li>
|
||||
<li>{plural(manifest.declaredVoteCount, 'voto declarado', 'votos declarados')}</li>
|
||||
{manifest.inviteCount > 0 ? (
|
||||
<li>{plural(manifest.inviteCount, 'convite pendente', 'convites pendentes')}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Conta de acesso ({manifest.accountName})</li>
|
||||
{manifest.authored.inviteCount > 0 ? (
|
||||
<li>{plural(manifest.authored.inviteCount, 'convite criado', 'convites criados')}</li>
|
||||
) : null}
|
||||
{manifest.authored.updateCount > 0 ? (
|
||||
<li>
|
||||
{plural(
|
||||
manifest.authored.updateCount,
|
||||
'atualização de município',
|
||||
'atualizações de município',
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.authored.feedCount > 0 ? (
|
||||
<li>{plural(manifest.authored.feedCount, 'link de agenda', 'links de agenda')}</li>
|
||||
) : null}
|
||||
{manifest.authored.importBatchCount > 0 ? (
|
||||
<li>
|
||||
{plural(
|
||||
manifest.authored.importBatchCount,
|
||||
'importação de apoiadores pendente',
|
||||
'importações de apoiadores pendentes',
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.assessorado.leadershipNames.length > 0 ||
|
||||
manifest.assessorado.deputyNames.length > 0 ||
|
||||
manifest.assessorado.activityNames.length > 0 ? (
|
||||
<li>
|
||||
Deixa de ser assessor responsável
|
||||
{manifest.assessorado.leadershipNames.length > 0
|
||||
? ` de ${manifest.assessorado.leadershipNames.join(', ')}`
|
||||
: ''}
|
||||
{manifest.assessorado.deputyNames.length > 0
|
||||
? `${manifest.assessorado.leadershipNames.length > 0 ? ' e' : ' de'} ${manifest.assessorado.deputyNames.join(', ')}`
|
||||
: ''}
|
||||
{manifest.assessorado.activityNames.length > 0
|
||||
? `${manifest.assessorado.leadershipNames.length > 0 || manifest.assessorado.deputyNames.length > 0 ? ' e' : ' de'} ${manifest.assessorado.activityNames.join(', ')}`
|
||||
: ''}
|
||||
.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button" className="min-h-11">
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<Button type="button" variant="destructive" className="min-h-11" onClick={onConfirm}>
|
||||
{isLeadership ? 'Encerrar liderança' : 'Encerrar assessoria'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getPersonCapacityExitManifestAction } from '@/app/(campaign)/campanha/actions/person'
|
||||
import { PeopleCapacityExitDialog } from '@/components/campaign/people/PeopleCapacityExitDialog'
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import {
|
||||
buildMunicipalityPortfolioChips,
|
||||
type MunicipalityPortfolioIndexEntry,
|
||||
} from '@/lib/municipalityPortfolio'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { PersonCapacityExitManifest } from '@/utilities/people/personCapacityExit'
|
||||
|
||||
type PeopleMunicipalityCellProps = {
|
||||
/** The entity the write targets (`leadership.id`, `stateDeputy.id` or the staff account). */
|
||||
ownerId: number | null
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
|
||||
ownerName: string
|
||||
/** C128 — the person ficha; the person-centric server actions resolve by it. */
|
||||
contactId: number
|
||||
/**
|
||||
* C128 — the capacity lifecycle policy of the column: when the LAST
|
||||
* municipality leaves, `account` and `leadership` confirm the destructive
|
||||
* exit through `PeopleCapacityExitDialog`; `stateDeputy` commits straight
|
||||
* (the row cleanup is automatic, no own campaign data — intention rabbit
|
||||
* hole). Omit for columns without an entity lifecycle.
|
||||
*/
|
||||
exitMode?: 'account' | 'leadership' | 'stateDeputy'
|
||||
municipalityIds: number[]
|
||||
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
|
||||
/** Ids the actor may ADD (administered carteira for an advisor). */
|
||||
addableIds?: ReadonlySet<number>
|
||||
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
|
||||
minItems?: number
|
||||
/** Read-only chips (actor may see the relation but not edit it). */
|
||||
readOnly?: boolean
|
||||
commitAction: (
|
||||
|
|
@ -30,21 +42,34 @@ type PeopleMunicipalityCellProps = {
|
|||
updateErrorMessage: string
|
||||
}
|
||||
|
||||
const mapManifestError = (error: unknown): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
return message || 'Não foi possível carregar o que será encerrado. Tente de novo.'
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — the people-list municipality columns (Assessora, Lidera, Aliada em):
|
||||
* the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
|
||||
* C116/C128 — the people-list municipality columns (Assessora, Lidera, Aliada
|
||||
* em): the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
|
||||
* always-input paradigm) plus the batch-chip expansion state — clicking a
|
||||
* territory / "Salvador (19)" chip expands it into its member municipalities
|
||||
* IN PLACE, and leaving the cell with the mouse collapses complete batches
|
||||
* again. Expansion is local presentation: it never touches the commit cycle.
|
||||
*
|
||||
* C128 — the destructive exit: when a removal would EMPTY the relation, the
|
||||
* commit is paused by `commitGuard` and, for `account`/`leadership`, the
|
||||
* confirmation dialog lists the manifest before the commit proceeds. The
|
||||
* entity (staff account / leadership / dobradinha) is created or deleted by
|
||||
* the person-centric server action; the cell commits even with a `null` owner
|
||||
* (the entity does not exist yet).
|
||||
*/
|
||||
export const PeopleMunicipalityCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
contactId,
|
||||
exitMode,
|
||||
municipalityIds,
|
||||
municipalityIndex,
|
||||
addableIds,
|
||||
minItems,
|
||||
readOnly = false,
|
||||
commitAction,
|
||||
drawerTitle,
|
||||
|
|
@ -54,6 +79,27 @@ export const PeopleMunicipalityCell = ({
|
|||
const idsRef = useRef(municipalityIds)
|
||||
idsRef.current = municipalityIds
|
||||
|
||||
/** The pending destructive exit: the dialog is up, the commit waits for it. */
|
||||
const [exitRequest, setExitRequest] = useState<PersonCapacityExitManifest | null>(null)
|
||||
const exitResolverRef = useRef<((confirmed: boolean) => void) | null>(null)
|
||||
/** A manifest round-trip in flight — a second guard must not orphan the first. */
|
||||
const manifestPendingRef = useRef(false)
|
||||
|
||||
/** Stable identity for the FormData extra-field map (memoized per contact). */
|
||||
const extraFormFields = useMemo(() => ({ contactId }), [contactId])
|
||||
|
||||
/**
|
||||
* Unmounting with the dialog open must settle the pending guard (resolve
|
||||
* `false` — nothing was confirmed, nothing commits) instead of leaving the
|
||||
* promise orphaned and the gesture silently lost.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
exitResolverRef.current?.(false)
|
||||
exitResolverRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleExpand = useCallback((chipKey: string) => {
|
||||
setExpandedKeys((current) => {
|
||||
const next = new Set(current)
|
||||
|
|
@ -89,6 +135,72 @@ export const PeopleMunicipalityCell = ({
|
|||
})
|
||||
}, [municipalityIndex])
|
||||
|
||||
/**
|
||||
* C128 — the destructive-exit guard, wired into `RelationChipCell` BEFORE
|
||||
* the optimistic apply: a removal that empties the relation pauses the
|
||||
* commit and (for account/leadership) asks the manifest-backed dialog.
|
||||
* Fail-closed: a manifest error aborts the commit — nothing is ever deleted
|
||||
* without an explicit confirmation. Returns `'destructive'` when the dialog
|
||||
* confirmed the exit, so the shared cell suppresses the batch-removal undo
|
||||
* toast — its "Desfazer" would only re-create the destroyed entity empty.
|
||||
*/
|
||||
const commitGuard = useCallback(
|
||||
async (delta: {
|
||||
changedIds: number[]
|
||||
assigned: boolean
|
||||
currentIds: number[]
|
||||
}): Promise<boolean | 'destructive'> => {
|
||||
if (!exitMode || delta.assigned) return true
|
||||
const remaining = delta.currentIds.filter((id) => !delta.changedIds.includes(id))
|
||||
if (remaining.length > 0) return true
|
||||
|
||||
// The dobradinha carries no campaign data of its own beyond the row —
|
||||
// the cleanup is automatic (intention rabbit hole), no dialog.
|
||||
if (exitMode === 'stateDeputy') return true
|
||||
|
||||
// A second guard while a dialog is pending OR a manifest round-trip is
|
||||
// in flight is refused: the pending commit must resolve first (two
|
||||
// concurrent guards would overwrite each other's resolver and orphan
|
||||
// the first commit silently).
|
||||
if (exitResolverRef.current || manifestPendingRef.current) return false
|
||||
manifestPendingRef.current = true
|
||||
|
||||
let manifest: PersonCapacityExitManifest | null
|
||||
try {
|
||||
manifest = await getPersonCapacityExitManifestAction({
|
||||
capacity: exitMode,
|
||||
contactId,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(mapManifestError(error))
|
||||
return false
|
||||
} finally {
|
||||
manifestPendingRef.current = false
|
||||
}
|
||||
// Nothing to destroy (no entity) — the server no-ops the removal.
|
||||
if (manifest === null) return true
|
||||
// A leadership with no declared votes and no invites dies silently —
|
||||
// there is nothing of its own to lose (intention: "com confirmação se
|
||||
// houver votos declarados").
|
||||
if (manifest.capacity === 'leadership') {
|
||||
if (manifest.declaredVoteCount === 0 && manifest.inviteCount === 0) return true
|
||||
}
|
||||
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
exitResolverRef.current = resolve
|
||||
setExitRequest(manifest)
|
||||
})
|
||||
return confirmed ? 'destructive' : false
|
||||
},
|
||||
[exitMode, contactId],
|
||||
)
|
||||
|
||||
const resolveExit = useCallback((confirmed: boolean) => {
|
||||
exitResolverRef.current?.(confirmed)
|
||||
exitResolverRef.current = null
|
||||
setExitRequest(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div onMouseLeave={handleMouseLeave}>
|
||||
<MunicipalityPortfolioCell
|
||||
|
|
@ -97,7 +209,6 @@ export const PeopleMunicipalityCell = ({
|
|||
municipalityIds={municipalityIds}
|
||||
municipalityIndex={municipalityIndex}
|
||||
addableIds={addableIds}
|
||||
minItems={minItems}
|
||||
commitAction={commitAction}
|
||||
drawerTitle={drawerTitle}
|
||||
updateErrorMessage={updateErrorMessage}
|
||||
|
|
@ -106,7 +217,19 @@ export const PeopleMunicipalityCell = ({
|
|||
onChipClick={toggleExpand}
|
||||
overflowToggleLabel={(count) => `+${count}`}
|
||||
readOnly={readOnly}
|
||||
extraFormFields={extraFormFields}
|
||||
commitWithNullOwner
|
||||
commitGuard={exitMode ? commitGuard : undefined}
|
||||
/>
|
||||
{exitRequest ? (
|
||||
<PeopleCapacityExitDialog
|
||||
open
|
||||
personName={ownerName}
|
||||
manifest={exitRequest}
|
||||
onConfirm={() => resolveExit(true)}
|
||||
onCancel={() => resolveExit(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,26 @@ type MunicipalityPortfolioCellProps = {
|
|||
overflowToggleLabel?: (hiddenCount: number) => string
|
||||
/** C116 — read-only chips (actor may see the relation but not edit it). */
|
||||
readOnly?: boolean
|
||||
/**
|
||||
* C128 — extra static fields appended to every commit FormData (e.g.
|
||||
* `contactId` for the person-centric people-list actions).
|
||||
*/
|
||||
extraFormFields?: Record<string, string | number>
|
||||
/**
|
||||
* C128 — commit even with a `null` owner: the people-list cells keep the
|
||||
* null owner while the entity does not exist yet, and the server-side person
|
||||
* action creates it. See `RelationChipCell.commitWithNullOwner`.
|
||||
*/
|
||||
commitWithNullOwner?: boolean
|
||||
/**
|
||||
* C128 — destructive-exit guard before the optimistic apply. See
|
||||
* `RelationChipCell.commitGuard`.
|
||||
*/
|
||||
commitGuard?: (delta: {
|
||||
changedIds: number[]
|
||||
assigned: boolean
|
||||
currentIds: number[]
|
||||
}) => Promise<boolean | 'destructive'>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -114,6 +134,9 @@ export const MunicipalityPortfolioCell = ({
|
|||
onChipClick,
|
||||
overflowToggleLabel,
|
||||
readOnly = false,
|
||||
extraFormFields,
|
||||
commitWithNullOwner = false,
|
||||
commitGuard,
|
||||
}: MunicipalityPortfolioCellProps) => {
|
||||
/**
|
||||
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
|
||||
|
|
@ -162,11 +185,14 @@ export const MunicipalityPortfolioCell = ({
|
|||
(changedIds: number[], assigned: boolean) => {
|
||||
const formData = new FormData()
|
||||
formData.set('ownerId', String(ownerId))
|
||||
for (const [name, value] of Object.entries(extraFormFields ?? {})) {
|
||||
formData.set(name, String(value))
|
||||
}
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
for (const id of changedIds) formData.append('municipalityIds', String(id))
|
||||
return formData
|
||||
},
|
||||
[ownerId],
|
||||
[ownerId, extraFormFields],
|
||||
)
|
||||
|
||||
const copy = useMemo<RelationChipCellCopy>(
|
||||
|
|
@ -206,6 +232,8 @@ export const MunicipalityPortfolioCell = ({
|
|||
overflowToggleLabel={overflowToggleLabel}
|
||||
onChipClick={onChipClick ? (chip) => onChipClick(chip.key) : undefined}
|
||||
readOnly={readOnly}
|
||||
commitWithNullOwner={commitWithNullOwner}
|
||||
commitGuard={commitGuard}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,31 @@ type RelationChipCellProps = {
|
|||
* "+N" chip label (e.g. "+3"), keeping the B170 measure/collapse contract.
|
||||
*/
|
||||
overflowToggleLabel?: (hiddenCount: number) => string
|
||||
|
||||
// C128 — the person lifecycle surface ───────────────────────────────────────
|
||||
/**
|
||||
* C128 — commit even when `ownerId` is `null`. The shared cells treat a null
|
||||
* owner as "draft row, nothing to write"; the people-list cells keep the
|
||||
* null owner on purpose (the entity does not exist yet) and the server-side
|
||||
* person actions resolve/create the entity by `contactId` from the FormData.
|
||||
*/
|
||||
commitWithNullOwner?: boolean
|
||||
/**
|
||||
* C128 — destructive-exit guard: called on removals (and additions) BEFORE
|
||||
* the optimistic apply, same point as the floor/cap refusals. Return `false`
|
||||
* to abort the commit without touching the chip state (the wrapper opens its
|
||||
* confirmation dialog), `true` to proceed, or `'destructive'` when the
|
||||
* wrapper confirmed a destructive exit — the caller then suppresses the
|
||||
* batch-removal undo toast, whose "Desfazer" would only re-create the
|
||||
* destroyed entity empty (votes/account are gone). `currentIds` is the
|
||||
* optimistic-aware effective set, so the wrapper can decide "does this
|
||||
* removal empty the relation?".
|
||||
*/
|
||||
commitGuard?: (delta: {
|
||||
changedIds: number[]
|
||||
assigned: boolean
|
||||
currentIds: number[]
|
||||
}) => Promise<boolean | 'destructive'>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -227,6 +252,8 @@ export const RelationChipCell = ({
|
|||
quiet = false,
|
||||
onChipClick,
|
||||
overflowToggleLabel,
|
||||
commitWithNullOwner = false,
|
||||
commitGuard,
|
||||
}: RelationChipCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -497,7 +524,7 @@ export const RelationChipCell = ({
|
|||
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
|
||||
* each compute from the set their own render captured and drop the other's work.
|
||||
*/
|
||||
const commit = (changedIds: number[], assigned: boolean) => {
|
||||
const commit = (changedIds: number[], assigned: boolean, suppressUndoToast = false) => {
|
||||
if (changedIds.length === 0) return
|
||||
|
||||
if (draft) {
|
||||
|
|
@ -515,7 +542,7 @@ export const RelationChipCell = ({
|
|||
setFeedback(null)
|
||||
|
||||
if (draft) return
|
||||
if (ownerId === null) return
|
||||
if (ownerId === null && !commitWithNullOwner) return
|
||||
|
||||
const formData = buildFormData(changedIds, assigned)
|
||||
|
||||
|
|
@ -525,8 +552,11 @@ export const RelationChipCell = ({
|
|||
setFeedback({ kind: 'saved' })
|
||||
// A batch removal drops several links in one tap, and the row it left
|
||||
// behind carries no trace of what was there. Single removals are one
|
||||
// search away from being restored and get no toast.
|
||||
if (!assigned && changedIds.length > 1) {
|
||||
// search away from being restored and get no toast. C128: a
|
||||
// destructive exit (the wrapper's guard confirmed the entity dies)
|
||||
// suppresses the toast — "Desfazer" would only re-create the destroyed
|
||||
// entity empty.
|
||||
if (!assigned && changedIds.length > 1 && !suppressUndoToast) {
|
||||
toast.success(copy.removedMessage(changedIds.length), {
|
||||
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
|
||||
})
|
||||
|
|
@ -542,6 +572,24 @@ export const RelationChipCell = ({
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* C128 — the destructive-exit guard runs here, BEFORE the optimistic apply
|
||||
* (same point as the floor/cap refusals): a `false` aborts with no state
|
||||
* touched — the wrapper's confirmation dialog decides. A `'destructive'`
|
||||
* verdict suppresses the batch-removal undo toast below (re-adding would
|
||||
* only re-create the destroyed entity empty — votes/account are gone). The
|
||||
* undo-toast path calls `commit` directly and never passes the guard
|
||||
* (re-adding can never empty a relation).
|
||||
*/
|
||||
const guardedCommit = async (changedIds: number[], assigned: boolean) => {
|
||||
let verdict: boolean | 'destructive' = true
|
||||
if (commitGuard) {
|
||||
verdict = await commitGuard({ changedIds, assigned, currentIds: effectiveIds })
|
||||
if (verdict === false) return
|
||||
}
|
||||
commit(changedIds, assigned, verdict === 'destructive')
|
||||
}
|
||||
|
||||
/** The relation's floor, refused here so the server never has to say no. */
|
||||
const removalFloorReason = (chip: RelationChip): string | null =>
|
||||
assignedIds.size - chip.ids.length < minItems
|
||||
|
|
@ -570,7 +618,7 @@ export const RelationChipCell = ({
|
|||
toast.error(`${floorReason}.`)
|
||||
return
|
||||
}
|
||||
commit(chip.ids, false)
|
||||
void guardedCommit(chip.ids, false)
|
||||
}
|
||||
|
||||
const pickHit = (hit: RelationSearchHit) => {
|
||||
|
|
@ -579,7 +627,7 @@ export const RelationChipCell = ({
|
|||
toast.error(capReason)
|
||||
return
|
||||
}
|
||||
commit(hit.ids, true)
|
||||
void guardedCommit(hit.ids, true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
|||
* (via the shared `contactFieldUpdateSchema`), the Assessora carteira batch
|
||||
* (unrestricted-only, person with exactly ONE staff account) and the
|
||||
* person-centric Assessorado advisor delta.
|
||||
*
|
||||
* C128 — the capacity-lifecycle memberships (Lidera / Aliada em) are
|
||||
* person-centric by `contactId` (the entity is resolved, created or deleted by
|
||||
* the server), and the destructive-exit manifest contract.
|
||||
*/
|
||||
|
||||
export const personAssessoraMembershipSchema = z.object({
|
||||
|
|
@ -15,14 +19,34 @@ export const personAssessoraMembershipSchema = z.object({
|
|||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const personLeadershipMembershipSchema = z.object({
|
||||
contactId: positiveRelationshipId,
|
||||
municipalityIds: z.array(positiveRelationshipId).min(1),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const personStateDeputyMembershipSchema = z.object({
|
||||
contactId: positiveRelationshipId,
|
||||
municipalityIds: z.array(positiveRelationshipId).min(1),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const personAdvisorMembershipSchema = z.object({
|
||||
contactId: positiveRelationshipId,
|
||||
advisorId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const personCapacityExitSchema = z.object({
|
||||
capacity: z.enum(['account', 'leadership']),
|
||||
contactId: positiveRelationshipId,
|
||||
})
|
||||
|
||||
export type PersonAssessoraMembershipInput = z.input<typeof personAssessoraMembershipSchema>
|
||||
export type PersonLeadershipMembershipInput = z.input<typeof personLeadershipMembershipSchema>
|
||||
export type PersonStateDeputyMembershipInput = z.input<typeof personStateDeputyMembershipSchema>
|
||||
export type PersonAdvisorMembershipInput = z.input<typeof personAdvisorMembershipSchema>
|
||||
export type PersonCapacityExitInput = z.input<typeof personCapacityExitSchema>
|
||||
|
||||
/** Refusal messages shared verbatim between the server action and the cell. */
|
||||
export const PERSON_CELL_STAFF_MESSAGE =
|
||||
|
|
@ -31,9 +55,9 @@ export const PERSON_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta pessoa não está no seu e
|
|||
export const PERSON_CONTACT_INVALID_MESSAGE = 'Esta pessoa não tem ficha de contato.'
|
||||
export const PERSON_ASSESSORA_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação e o candidato podem editar a carteira de assessores.'
|
||||
export const PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE =
|
||||
'Esta pessoa não tem perfil de assessor para vincular municípios.'
|
||||
export const PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE =
|
||||
'Esta pessoa tem mais de um perfil de assessor; edite os municípios por perfil no admin.'
|
||||
export const PERSON_ADVISORS_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação e o candidato podem editar os assessores.'
|
||||
export const PERSON_CAPACITY_EXIT_SCOPE_MESSAGE =
|
||||
'Você só pode encerrar a capacidade quando todos os municípios dela estão na sua carteira.'
|
||||
|
|
|
|||
40
src/utilities/campaignStubEmail.ts
Normal file
40
src/utilities/campaignStubEmail.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'server-only'
|
||||
|
||||
import { stubCampaignUserEmailFor } from '@/lib/schemas/advisor'
|
||||
import { MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE } from '@/lib/schemas/municipality'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/**
|
||||
* C128 — the next free `<slug>@criado.invalid` stub for an account created
|
||||
* without usable credentials (the "login is provisioned later" contract). Was
|
||||
* private to `actions/municipality.ts` (B154); the person lifecycle
|
||||
* (`setPersonAssessoraMembershipRecord`) creates staff accounts too, and the
|
||||
* unique-email probe must never diverge between the two surfaces.
|
||||
*
|
||||
* Bounded exact-match probes on the unique `email` index (no LIKE semantics).
|
||||
* Collisions only happen when two names slugify identically, so the first
|
||||
* probe usually succeeds. The read bypasses access control on purpose: the
|
||||
* caller's gate (unrestricted role) already ran in its transaction, and the
|
||||
* probe only looks at placeholder e-mails.
|
||||
*/
|
||||
export const nextFreeStubCampaignEmail = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
req: PayloadTransactionRequest,
|
||||
name: string,
|
||||
): Promise<string> => {
|
||||
for (let occurrence = 1; occurrence <= 50; occurrence += 1) {
|
||||
const candidate = stubCampaignUserEmailFor(name, occurrence)
|
||||
const existing = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { email: { equals: candidate } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { email: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (existing.totalDocs === 0) return candidate
|
||||
}
|
||||
throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE)
|
||||
}
|
||||
21
src/utilities/leadershipConflict.ts
Normal file
21
src/utilities/leadershipConflict.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import 'server-only'
|
||||
|
||||
/**
|
||||
* C128 — shared leadership identity-conflict matcher. Was private to
|
||||
* `actions/leadership.ts` (create path); the person lifecycle
|
||||
* (`setPersonLeadershipMunicipalitiesRecord`) creates leaderships too, and the
|
||||
* `contact` unique violation must map to the same safe message in both
|
||||
* surfaces. Lives outside the 'use server' module because a plain const cannot
|
||||
* be re-exported from one (Next.js allows only async function exports there).
|
||||
*/
|
||||
|
||||
export const isUniqueLeadershipConflict = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/leadership_contact|duplicate key/i.test(message)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(error instanceof Error) || error.name !== 'ValidationError') return false
|
||||
|
||||
return /contact(?:_id)?/i.test(JSON.stringify(error))
|
||||
}
|
||||
280
src/utilities/people/personCapacityExit.ts
Normal file
280
src/utilities/people/personCapacityExit.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import {
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
PERSON_CAPACITY_EXIT_SCOPE_MESSAGE,
|
||||
type PersonCapacityExitInput,
|
||||
} from '@/lib/schemas/personCell'
|
||||
import type { CampaignUser, StateDeputy } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
|
||||
/**
|
||||
* C128 — the destructive-exit contract of the people list: when the LAST
|
||||
* municipality of a capacity leaves the row, the entity (staff account,
|
||||
* leadership, dobradinha) dies server-side. Two guards, shared by the exit
|
||||
* write (inside the transaction) and the confirmation manifest (read-only
|
||||
* preview) so the dialog can never describe a destruction the write would
|
||||
* refuse, or vice-versa.
|
||||
*
|
||||
* - Scope (Lidera / Aliada em): coordinator/candidate always pass; an advisor
|
||||
* only passes when EVERY municipality the capacity currently holds is in his
|
||||
* carteira — the chips visibility already prevents the gesture otherwise,
|
||||
* this is the server-side re-check ("o piso dele é a visibilidade").
|
||||
* - Account (Assessora): coordinator/candidate only — the intent's gate
|
||||
* decision (criação/remoção de conta staff é coordenação/candidato).
|
||||
*
|
||||
* The manifest is a preview, never the authority: the exit write re-enumerates
|
||||
* inside its transaction (same contract as `loadPersonDeleteManifest`).
|
||||
*/
|
||||
|
||||
export type PersonCapacityExitManifest =
|
||||
| {
|
||||
capacity: 'leadership'
|
||||
declaredVoteCount: number
|
||||
inviteCount: number
|
||||
municipalityNames: string[]
|
||||
}
|
||||
| {
|
||||
capacity: 'account'
|
||||
accountName: string
|
||||
authored: {
|
||||
inviteCount: number
|
||||
updateCount: number
|
||||
feedCount: number
|
||||
importBatchCount: number
|
||||
}
|
||||
assessorado: { leadershipNames: string[]; deputyNames: string[]; activityNames: string[] }
|
||||
}
|
||||
|
||||
type CapacityExitPayload = Pick<Payload, 'find' | 'count' | 'findByID'>
|
||||
|
||||
export const assertPersonCapacityExitScope = async (
|
||||
payload: CapacityExitPayload,
|
||||
currentActor: CampaignUser,
|
||||
req: PayloadTransactionRequest | undefined,
|
||||
currentMunicipalityIDs: readonly number[],
|
||||
): Promise<void> => {
|
||||
if (isCampaignUnrestricted(currentActor)) return
|
||||
if (currentActor.role !== 'advisor') {
|
||||
throw new Error(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
const administered = new Set(await getAdvisorMunicipalityIds(payload, currentActor.id, req))
|
||||
const outside = currentMunicipalityIDs.filter((id) => !administered.has(id))
|
||||
if (outside.length > 0) {
|
||||
throw new Error(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
const assessoradoNamesOf = async (
|
||||
payload: CapacityExitPayload,
|
||||
accountID: number,
|
||||
req: PayloadTransactionRequest | undefined,
|
||||
): Promise<{ leadershipNames: string[]; deputyNames: string[]; activityNames: string[] }> => {
|
||||
const [leaderships, deputies, activities] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { advisors: { contains: accountID } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
// Intentional admin bypass: the unrestricted gate (or the exit scope) is
|
||||
// the authorization; the manifest only resolves names to display.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { advisors: { contains: accountID } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
// Intentional admin bypass: same gate as the leadership read above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'activity',
|
||||
where: { responsible: { equals: { relationTo: 'campaignUser', value: accountID } } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { title: true },
|
||||
// Intentional admin bypass: same gate as the leadership read above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
const nameOf = (contact: unknown): string =>
|
||||
contact !== null && typeof contact === 'object' && 'name' in contact
|
||||
? String((contact as { name: unknown }).name ?? 'Contato')
|
||||
: 'Contato'
|
||||
|
||||
return {
|
||||
leadershipNames: leaderships.docs.map((doc) => nameOf((doc as { contact: unknown }).contact)),
|
||||
deputyNames: deputies.docs.map((doc) => nameOf((doc as StateDeputy).contact)),
|
||||
activityNames: activities.docs
|
||||
.map((doc) => (doc as { title?: string | null }).title ?? 'Atividade')
|
||||
.filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only preview of what the destructive exit removes. `null` means there
|
||||
* is nothing to destroy (no entity for the capacity) — the caller commits the
|
||||
* plain municipality change (a server no-op) without a dialog. Throws on the
|
||||
* scope refusals so the dialog never opens for a destruction that would fail.
|
||||
*/
|
||||
export const loadPersonCapacityExitManifest = async (
|
||||
payload: CapacityExitPayload,
|
||||
actor: CampaignUser,
|
||||
input: PersonCapacityExitInput,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<PersonCapacityExitManifest | null> => {
|
||||
if (input.capacity === 'account') {
|
||||
if (!isCampaignUnrestricted(actor)) {
|
||||
throw new Error(PERSON_CAPACITY_EXIT_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
const accounts = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { contact: { equals: input.contactId } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
// Intentional admin bypass: the unrestricted gate above is the
|
||||
// authorization; `contact` is identity-gated and only counted here.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (accounts.docs.length === 0) return null
|
||||
if (accounts.docs.length > 1) {
|
||||
throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
|
||||
}
|
||||
const accountID = Number(accounts.docs[0].id)
|
||||
|
||||
const [invites, updates, feeds, batches, assessorado] = await Promise.all([
|
||||
// Intentional admin bypass: the unrestricted gate above is the
|
||||
// authorization; these counts only size the manifest list.
|
||||
payload.count({
|
||||
collection: 'campaignInvite',
|
||||
where: { createdBy: { equals: accountID } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { author: { equals: accountID } },
|
||||
// Intentional admin bypass: same unrestricted gate as above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'calendarFeed',
|
||||
where: { createdBy: { equals: accountID } },
|
||||
// Intentional admin bypass: same unrestricted gate as above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
// Intentional admin bypass: same unrestricted gate as above.
|
||||
payload.count({
|
||||
collection: 'supporterImportBatch',
|
||||
where: { actor: { equals: accountID } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
assessoradoNamesOf(payload, accountID, req),
|
||||
])
|
||||
|
||||
return {
|
||||
capacity: 'account',
|
||||
accountName: accounts.docs[0].name,
|
||||
authored: {
|
||||
inviteCount: invites.totalDocs,
|
||||
updateCount: updates.totalDocs,
|
||||
feedCount: feeds.totalDocs,
|
||||
importBatchCount: batches.totalDocs,
|
||||
},
|
||||
assessorado,
|
||||
}
|
||||
}
|
||||
|
||||
const leaderships = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: input.contactId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { id: true, municipalities: true },
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
if (leaderships.docs.length === 0) return null
|
||||
const leadershipID = leaderships.docs[0].id
|
||||
|
||||
const currentMunicipalityIDs = uniqueRelationshipIds(
|
||||
(leaderships.docs[0] as { municipalities: unknown[] }).municipalities,
|
||||
)
|
||||
await assertPersonCapacityExitScope(payload, actor, req, currentMunicipalityIDs)
|
||||
|
||||
const [pledges, invites, municipalities] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'votePledge',
|
||||
where: { leadership: { equals: leadershipID } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { declaredVotes: true },
|
||||
// Intentional admin bypass: the exit scope was checked above; the
|
||||
// manifest only sums the votes the deletion would remove.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'campaignInvite',
|
||||
where: { leadership: { equals: leadershipID } },
|
||||
// Intentional admin bypass: same exit scope as the pledges read above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { in: currentMunicipalityIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
// Intentional admin bypass: same exit scope as the pledges read above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
const nameById = new Map(
|
||||
municipalities.docs.map((municipality) => [municipality.id, municipality.name] as const),
|
||||
)
|
||||
|
||||
return {
|
||||
capacity: 'leadership',
|
||||
declaredVoteCount: pledges.docs.reduce(
|
||||
(sum, pledge) =>
|
||||
sum +
|
||||
(typeof (pledge as { declaredVotes?: number | null }).declaredVotes === 'number'
|
||||
? ((pledge as { declaredVotes: number }).declaredVotes ?? 0)
|
||||
: 0),
|
||||
0,
|
||||
),
|
||||
inviteCount: invites.totalDocs,
|
||||
municipalityNames: currentMunicipalityIDs
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((name): name is string => name !== undefined),
|
||||
}
|
||||
}
|
||||
|
|
@ -332,6 +332,61 @@ export type PersonDeleteResult = {
|
|||
deletedAccounts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes ONE staff account with everything that would block the row: invites
|
||||
* the account CREATED for other leaderships, authored municipality updates,
|
||||
* calendar feeds and import batches all carry NOT NULL FKs with `ON DELETE set
|
||||
* null` — they must go BEFORE the account itself. Per-id so
|
||||
* `CampaignUser.beforeDelete` (passkeys + notifications) runs. The advisor
|
||||
* join tables (`municipality_rels`, `activity_rels`, `leadership_rels`,
|
||||
* `state_deputy_rels`) cascade on `campaign_user_id` on their own.
|
||||
*
|
||||
* Shared by the person cascade and the C128 "fim de assessoria" (last
|
||||
* Assessora municipality removed) — the account lifecycle must never diverge
|
||||
* between the two surfaces.
|
||||
*/
|
||||
export const deleteCampaignUserAccount = async (
|
||||
payload: PersonDeletePayload,
|
||||
req: PayloadTransactionRequest,
|
||||
accountID: number,
|
||||
): Promise<void> => {
|
||||
await payload.delete({
|
||||
collection: 'campaignInvite',
|
||||
where: { createdBy: { equals: accountID } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { author: { equals: accountID } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'calendarFeed',
|
||||
where: { createdBy: { equals: accountID } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'supporterImportBatch',
|
||||
where: { actor: { equals: accountID } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'campaignUser',
|
||||
id: accountID,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The destructive cascade, one transaction, one advisory lock per person.
|
||||
* Re-enumerates inside the transaction (the manifest is a preview, never the
|
||||
|
|
@ -393,37 +448,14 @@ export const deletePersonRecord = async (
|
|||
})
|
||||
}
|
||||
if (accountIDs.length > 0) {
|
||||
// Invites the deleted accounts CREATED for other leaderships, authored
|
||||
// municipality updates and calendar feeds all carry NOT NULL FKs with
|
||||
// `ON DELETE set null` — they must go before the accounts themselves.
|
||||
await payload.delete({
|
||||
collection: 'campaignInvite',
|
||||
where: { createdBy: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { author: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'calendarFeed',
|
||||
where: { createdBy: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'supporterImportBatch',
|
||||
where: { actor: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs
|
||||
// for every account — bulk `where` deletes would skip the hooks. The
|
||||
// helper also removes the NOT NULL authored rows (invites created by the
|
||||
// account, municipality updates, calendar feeds, import batches) before
|
||||
// the account row itself.
|
||||
for (const accountID of accountIDs) {
|
||||
await deleteCampaignUserAccount(payload, req, accountID)
|
||||
}
|
||||
}
|
||||
if (supporterCount > 0) {
|
||||
await payload.delete({
|
||||
|
|
@ -464,18 +496,6 @@ export const deletePersonRecord = async (
|
|||
}
|
||||
}
|
||||
|
||||
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs for
|
||||
// every account — bulk `where` deletes would skip the hooks.
|
||||
for (const accountID of accountIDs) {
|
||||
await payload.delete({
|
||||
collection: 'campaignUser',
|
||||
id: accountID,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
let contactDeleted = false
|
||||
let contactAnonymized = false
|
||||
if (signatureCount > 0 || subscriptionCount > 0) {
|
||||
|
|
|
|||
23
src/utilities/stateDeputyConflict.ts
Normal file
23
src/utilities/stateDeputyConflict.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'server-only'
|
||||
|
||||
import {
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import type { StaffEntityPolicy } from '@/utilities/campaignEntityActions'
|
||||
|
||||
/**
|
||||
* C128 — shared dobradinha identity-conflict policy. Was private to
|
||||
* `actions/stateDeputy.ts`; the person lifecycle
|
||||
* (`setPersonStateDeputyMunicipalitiesRecord`) creates dobradinhas too, and
|
||||
* the `contact`/`slug` unique violations must map to the same safe message in
|
||||
* both surfaces. Lives outside the 'use server' module because a plain const
|
||||
* cannot be re-exported from one (Next.js allows only async function exports
|
||||
* there).
|
||||
*/
|
||||
|
||||
export const stateDeputyPolicy: StaffEntityPolicy = {
|
||||
staffMessage: STATE_DEPUTY_STAFF_MESSAGE,
|
||||
conflictPattern: /state_deputy_(contact|slug)|duplicate key/i,
|
||||
conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
}
|
||||
|
|
@ -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$/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -415,6 +415,10 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'campaignBiometricsPrompt.ts',
|
||||
'campaignColumnVisibilityCookie.ts',
|
||||
'campaignConsent.ts',
|
||||
// C128 — stub `<slug>@criado.invalid` e-mail probe for staff accounts
|
||||
// created without usable credentials; shared by the B154 município create
|
||||
// and the person lifecycle Assessora create.
|
||||
'campaignStubEmail.ts',
|
||||
'campaignDashboardData.ts',
|
||||
'campaignVoteSummarySnapshot.ts',
|
||||
'campaignDemandData.ts',
|
||||
|
|
@ -459,6 +463,10 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'formatRelativeAge.ts',
|
||||
'globalReads.ts',
|
||||
'globals.ts',
|
||||
// C128 — leadership identity-conflict matcher; shared by the B34 create
|
||||
// path and the person lifecycle create (a plain const cannot leave the
|
||||
// 'use server' action module).
|
||||
'leadershipConflict.ts',
|
||||
'hookFilledData.ts',
|
||||
'leaderContactsPageData.ts',
|
||||
'loadNamesByIds.ts',
|
||||
|
|
@ -472,6 +480,10 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'sameOriginRequest.ts',
|
||||
'seo.ts',
|
||||
'signatureExport.ts',
|
||||
// C128 — dobradinha identity-conflict policy; shared by the B157 create
|
||||
// path and the person lifecycle create (same 'use server' export rule as
|
||||
// `leadershipConflict.ts`).
|
||||
'stateDeputyConflict.ts',
|
||||
'stateDeputyData.ts',
|
||||
'stateDeputyListFilters.ts',
|
||||
'stateDeputyListUrl.ts',
|
||||
|
|
|
|||
174
tests/unit/peopleMunicipalityCell.unit.spec.tsx
Normal file
174
tests/unit/peopleMunicipalityCell.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/actions/person', () => ({
|
||||
getPersonCapacityExitManifestAction: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getPersonCapacityExitManifestAction } from '@/app/(campaign)/campanha/actions/person'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
}))
|
||||
|
||||
const INDEX: MunicipalityPortfolioIndexEntry[] = [
|
||||
{ id: 1, slug: 'ilheus' },
|
||||
{ id: 2, slug: 'itabuna' },
|
||||
]
|
||||
|
||||
const manifestAction = vi.mocked(getPersonCapacityExitManifestAction)
|
||||
|
||||
let commitCalls: FormData[] = []
|
||||
|
||||
const success = async (): Promise<CampaignFormActionState> => ({ status: 'success' })
|
||||
|
||||
const cell = (
|
||||
props: Partial<Parameters<typeof PeopleMunicipalityCell>[0]> & {
|
||||
municipalityIds: number[]
|
||||
},
|
||||
) =>
|
||||
createElement(PeopleMunicipalityCell, {
|
||||
ownerId: null,
|
||||
ownerName: 'Maria Souza',
|
||||
contactId: 42,
|
||||
municipalityIndex: INDEX,
|
||||
commitAction: async (_state: CampaignFormActionState, formData: FormData) => {
|
||||
commitCalls.push(formData)
|
||||
return success()
|
||||
},
|
||||
drawerTitle: 'Editar municípios',
|
||||
updateErrorMessage: 'Não foi possível atualizar os municípios.',
|
||||
...props,
|
||||
})
|
||||
|
||||
const formEntry = (formData: FormData, name: string): string | null => {
|
||||
const value = formData.get(name)
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
commitCalls = []
|
||||
manifestAction.mockReset()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('C128 — people municipality cell lifecycle', () => {
|
||||
it('commits an ADDITION with a null owner, carrying the contactId (C116 assessora bug fix)', async () => {
|
||||
render(cell({ municipalityIds: [] }))
|
||||
|
||||
const search = screen.getByRole('combobox', {
|
||||
name: 'Buscar município, território de identidade ou zona eleitoral',
|
||||
})
|
||||
fireEvent.change(search, { target: { value: 'Itabuna' } })
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('option', { name: /Itabuna/ }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
|
||||
const formData = commitCalls[0]!
|
||||
expect(formEntry(formData, 'contactId')).toBe('42')
|
||||
expect(formEntry(formData, 'ownerId')).toBe('null')
|
||||
expect(formData.getAll('municipalityIds')).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('pauses the LAST removal behind the confirmation dialog and aborts on cancel', async () => {
|
||||
manifestAction.mockResolvedValue({
|
||||
capacity: 'account',
|
||||
accountName: 'Maria Souza',
|
||||
authored: { inviteCount: 0, updateCount: 0, feedCount: 0, importBatchCount: 0 },
|
||||
assessorado: { leadershipNames: [], deputyNames: [], activityNames: [] },
|
||||
})
|
||||
render(cell({ municipalityIds: [1], exitMode: 'account' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: 'Encerrar assessoria' })).toBeTruthy(),
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancelar' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('heading', { name: 'Encerrar assessoria' })).toBeNull(),
|
||||
)
|
||||
expect(commitCalls).toHaveLength(0)
|
||||
// The optimistic apply never happened — the chip is still there.
|
||||
expect(screen.getByText('Ilhéus')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits the LAST removal after the dialog confirms', async () => {
|
||||
manifestAction.mockResolvedValue({
|
||||
capacity: 'account',
|
||||
accountName: 'Maria Souza',
|
||||
authored: { inviteCount: 0, updateCount: 0, feedCount: 0, importBatchCount: 0 },
|
||||
assessorado: { leadershipNames: [], deputyNames: [], activityNames: [] },
|
||||
})
|
||||
render(cell({ municipalityIds: [1], exitMode: 'account' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: 'Encerrar assessoria' })).toBeTruthy(),
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Encerrar assessoria' }))
|
||||
|
||||
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
|
||||
expect(formEntry(commitCalls[0]!, 'contactId')).toBe('42')
|
||||
expect(formEntry(commitCalls[0]!, 'assigned')).toBe('false')
|
||||
})
|
||||
|
||||
it('skips the dialog for a leadership with nothing to lose', async () => {
|
||||
manifestAction.mockResolvedValue({
|
||||
capacity: 'leadership',
|
||||
declaredVoteCount: 0,
|
||||
inviteCount: 0,
|
||||
municipalityNames: ['Ilhéus'],
|
||||
})
|
||||
render(cell({ municipalityIds: [1], exitMode: 'leadership' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
|
||||
|
||||
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
|
||||
expect(screen.queryByRole('heading', { name: 'Encerrar liderança' })).toBeNull()
|
||||
})
|
||||
|
||||
it('skips the dialog for a stateDeputy exit (auto-cleanup)', async () => {
|
||||
render(cell({ municipalityIds: [1], exitMode: 'stateDeputy' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
|
||||
|
||||
await waitFor(() => expect(commitCalls).toHaveLength(1), { timeout: 5000 })
|
||||
expect(manifestAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the commit when the manifest fails (fail-closed)', async () => {
|
||||
manifestAction.mockRejectedValue(new Error('Não foi possível carregar o que será encerrado.'))
|
||||
render(cell({ municipalityIds: [1], exitMode: 'account' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ilhéus' }))
|
||||
|
||||
await waitFor(() => expect(manifestAction).toHaveBeenCalled())
|
||||
expect(commitCalls).toHaveLength(0)
|
||||
expect(screen.getByText('Ilhéus')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue