From 26b2205c3a7b23ae36222b0f572b3402a1f432cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Mon, 3 Aug 2026 22:36:00 -0300 Subject: [PATCH 1/2] =?UTF-8?q?B154=20=E2=80=94=20criar=20assessor=20inlin?= =?UTF-8?q?e=20no=20popover=20da=20lista=20de=20munic=C3=ADpios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #352 - POST /campanha/municipios/advisors aceita body de criação (name) na union; createMunicipalityAdvisorRecord cria campaignUser advisor (stub @criado.invalid, senha aleatória) e atribui ao município na mesma transação (cap 10 com rollback, lock municipality-advisors:{id}) - MunicipalityAdvisorCreateProvider compartilha o assessor criado entre linhas; opção "+ Criar assessor" e chip otimista com id temporário no control - advisorNameSchema unificado em primitives.ts; predicado de placeholder cobre @criado.invalid (reset de senha e célula de e-mail tratam stubs inline) - Destrava o spec e2e de municipios (falhas pré-existentes em main): key warning do fragment trailing de MunicipalityFilters; settle-wait da animação do Drawer no teste B136 --- docs/CHANGELOG-AGENTS.md | 2 + ...assessor-inline-popover-municipios-impl.md | 142 ++++++++++++ .../(app)/municipios/advisors/route.ts | 52 ++++- .../(app)/municipios/advisors/types.ts | 2 + .../campanha/actions/municipality.ts | 129 +++++++++++ .../MunicipalityAdvisorCreateProvider.tsx | 46 ++++ .../municipality/MunicipalityFilters.tsx | 16 +- .../municipality/MunicipalityList.tsx | 5 +- .../MunicipalityListAdvisorsControl.tsx | 207 +++++++++++++++--- src/lib/schemas/advisor.ts | 27 ++- src/lib/schemas/municipality.ts | 28 ++- src/lib/schemas/primitives.ts | 8 + tests/e2e/campaignMunicipalities.e2e.spec.ts | 77 +++++++ ...nMunicipalityAdvisorMembership.int.spec.ts | 122 ++++++++++- tests/unit/advisorSchema.unit.spec.ts | 26 +++ ...ignMunicipalityAdvisorsCreate.unit.spec.ts | 87 ++++++++ ...aignMunicipalityAdvisorsRoute.unit.spec.ts | 88 ++++++++ 17 files changed, 1007 insertions(+), 57 deletions(-) create mode 100644 docs/plans/criar-assessor-inline-popover-municipios-impl.md create mode 100644 src/components/campaign/municipality/MunicipalityAdvisorCreateProvider.tsx create mode 100644 tests/unit/campaignMunicipalityAdvisorsCreate.unit.spec.ts create mode 100644 tests/unit/campaignMunicipalityAdvisorsRoute.unit.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 1209f702..8c166a59 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-03):** **B154** (Issue #352) — criar assessor inline no popover da lista de municípios: opção "+ Criar assessor ''" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `@criado.invalid`, senha aleatória) e atribui ao município na MESMA transação (cap 10 com rollback, lock `municipality-advisors:{id}`); provider client compartilha o assessor criado entre linhas; chip otimista com id temporário negativo; `advisorNameSchema` unificado em `primitives.ts`; predicado de placeholder estende a `@criado.invalid`. Bônus pré-existentes destravando o e2e do spec municipios: key warning do fragment `trailing` de `MunicipalityFilters` (array com keys) e settle-wait da animação do Drawer no teste B136. Intenção [`criar-assessor-inline-popover-municipios.md`](docs/plans/criar-assessor-inline-popover-municipios.md); impl [`criar-assessor-inline-popover-municipios-impl.md`](docs/plans/criar-assessor-inline-popover-municipios-impl.md). + **Recently resolved (2026-08-03):** **B86** (Issue #22) — ações rápidas de apoiadores (staff), último catálogo do lote B80–B90: módulo `supporterQuickActions.ts` (espelha B87) com `register-supporter` → `/apoiadores/novo` (todo staff) e `import-supporters` → `/apoiadores/importar` (coordinator-only, espelha `isCampaignCoordinator`); superfície lista+detalhe numérico; `novo`/`importar` só-busca; leader fora. Sem migration/Consent. Impl [`acoes-rapidas-apoiadores-impl.md`](docs/plans/acoes-rapidas-apoiadores-impl.md); débito S1 (normalização de pathname de lista, 6+ call sites) → #353 [`escala-dry-pos-acoes-rapidas.md`](docs/plans/escala-dry-pos-acoes-rapidas.md). **Recently resolved (2026-08-03):** **Pass 5 P1** — push de notificação de campanha após commit: `onPayloadTransactionCommit` em `withPayloadTransaction`; `createCampaignNotification` não usa mais `queueMicrotask` sob `req.transactionID`; `sendCampaignPushForNotification` soft-fail se a row sumiu. Guarda `notificationPushScheduleConventions`. diff --git a/docs/plans/criar-assessor-inline-popover-municipios-impl.md b/docs/plans/criar-assessor-inline-popover-municipios-impl.md new file mode 100644 index 00000000..142cd3cf --- /dev/null +++ b/docs/plans/criar-assessor-inline-popover-municipios-impl.md @@ -0,0 +1,142 @@ +# Impl: Criar assessor inline no popover da lista de municípios + +Status: aprovado +Atualizado em: 2026-08-03 +Issue: #352 +Intenção: docs/plans/criar-assessor-inline-popover-municipios.md +Appetite restante: herdado (~0,5 dia eng) + +## Leitura da intenção + +- **Outcome:** o coordenador cria uma conta `campaignUser` com papel `advisor` **só com o nome**, dentro do popover "Atribuir assessores" de `/campanha/municipios`, com atribuição automática ao município atual; chip otimista, campo limpo, popover aberto; erro visível no próprio popover quando a criação falha; a conta não faz login até um coordenador trocar credenciais em `/campanha/assessores/[id]`. +- **O que NÃO negociar:** não é formulário completo (anti-goal: sem e-mail/celular/senha/foto inline); papel sempre `advisor`; indisponível para `leader` (que já não vê o popover — a lista de elegíveis é `eligibleCampaignStaffWhere`); e-mail stub `@criado.invalid` (padrão E4R, travado no gate 2026-08-03); faceta `?advisor=` reconcilia só na próxima navegação (contrato B24/B27); dedup por similaridade de nome fica de fora. +- **O que reavaliar:** + - A intenção fala do "`advisorLookup` (memo do `MunicipalityListAdvisorsControl`) incluir o assessor recém-criado". Um memo por instância só cobre a linha que criou — o coordenador abre o popover de **outro** município na mesma reunião e o assessor novo precisa estar lá. Isso exige estado compartilhado entre linhas (provider client), não só o memo. + - Colisão do e-mail stub não foi decidida no gate: dois nomes com o mesmo slug (`João` × 2) gerariam o mesmo `joao@criado.invalid` e o `email` é `unique`. Precisa de dedup determinístico. + - "Estado otimista" do chip: o id do assessor não existe até a resposta — o otimismo exige um id temporário, disjunto dos ids reais. + +## Abordagem recomendada + +```mermaid +flowchart LR + cmd["Command: digita 'Carlos'
sem match"] -->|"filteredOptions == 0
e query ≥ 2"| createItem["+ Criar assessor 'Carlos'"] + createItem -->|"Enter/clique"| optimistic["temp id negativo no selectedIDs
+ pendingCreates + spinner"] + optimistic --> post["POST /campanha/municipios/advisors
body { municipalityId, name }"] + post --> route["body union: 'name' in body?"] + route -->|"create"| record["createMunicipalityAdvisorRecord
(mesma transação + lock)"] + record -->|"1) probe stub livre"| u["payload.create campaignUser
(user + overrideAccess:false + req)"] + u -->|"2) cap via nextAdvisorIdsAfterMembership"| m["payload.update municipality.advisors"] + record --> resp["{ advisors, createdAdvisor: { id, name } }"] + resp -->|"settle (0 pendências)"| adopt["adota set confirmado
+ registra no provider"] + post -.erro.-> alert["revert temp + Alert no popover"] +``` + +**Opções consideradas:** ver "Decisões de engenharia" (D1–D6). +**Recomendação:** endpoint sobrecarregado com body-union; record novo no mesmo arquivo da action do toggle (transação única: cria conta + atribui, cap com rollback); provider client para o assessor criado virar opção em todas as linhas; chip otimista com id temporário negativo reusando o maquinário de seq/pendências do B27. +**Rejeitadas:** rota nova (`advisors/create`); reusar `createAdvisorRecord` do B19 fora de transação; estado local por instância; `router.refresh()` após criar; aguardar a resposta para mostrar o chip; deixar o unique violation virar erro. + +### Decisões de engenharia + +**D1 — Endpoint.** +Opções: A) sobrecarregar `POST /campanha/municipios/advisors` com body-union `toggle | create` | B) rota nova `advisors/create` | C) um objeto único com `name`/`advisorId` opcionais + superRefine. +Recomendação: **A** — é a decisão travada no gate, e a union é type-honest: os dois shapes são mutuamente exclusivos, impossível enviar `name` + `assigned: false`. O toggle existente fica byte a byte igual (`'name' in body` ramifica). +Rejeitadas: B — duplica o shell `campaignJsonMutationRoute` (guarda de origem, parse, mapeamento de erro) e contraria o gate; C — permite combinações impossíveis e o tipo não reflete o contrato. + +**D2 — Onde cria a conta.** +Opções: A) `createMunicipalityAdvisorRecord` em `actions/municipality.ts` (mesmo arquivo do toggle), na MESMA transação do assign | B) reusar `createAdvisorRecord` (B19) + toggle separado | C) server action de formulário. +Recomendação: **A** — uma transação (`withPayloadTransaction`) cria o `campaignUser` e atribui ao município: se o cap de 10 estoura, o rollback não deixa conta órfã. Reusa `acquireTextAdvisoryLocks(['municipality-advisors:{id}'])`, `nextAdvisorIdsAfterMembership` (fonte única do cap), `hookFilledCreateData`, `randomBytes`, `reloadUnrestrictedActor` com a mensagem de membership. +Rejeitadas: B — `createAdvisorRecord` não recebe `req` (fora de transação), revalida caminhos do B19 e não atribui; C — form action fecha popover/toast, exatamente a razão do B27 ter escolhido endpoint JSON. + +**D3 — E-mail stub e colisão.** +Opções (colisão): A) probe determinístico de e-mails livres na transação (`joao@criado.invalid`, `joao-2@…`, `joao-3@…`) | B) deixar o unique violation virar erro genérico | C) sufixo aleatório. +Recomendação: **A** — o "Criar assessor 'João'" sempre funciona (nome não é unique, conta duplicada de nome é legal); helper puro `stubCampaignUserEmailFor(name, occurrence)` testável; probe por `equals` em loop bounded (sem semântica de `like`). **Bônus (mesmo comportamento do aceite):** generalizar o predicado de placeholder para cobrir `@criado.invalid` além de `@planilha.invalid` — assim o reset de senha (`PLACEHOLDER_RESET_MESSAGE`) e a célula de e-mail de `/campanha/assessores` (`AdvisorDebouncedTextCell`) tratam stubs inline como os do seed E4R. +Rejeitadas: B — mensagem enganosa (o coordenador digitou nome, não e-mail) e colapsaria para o genérico; C — não determinístico, pior para reconciliação futura. + +**D4 — Estado compartilhado entre linhas.** +Opções: A) provider client `MunicipalityAdvisorCreateProvider` montado em `MunicipalityList` (server) envolvendo mobile + tabela, com `children` server | B) estado local por instância do control | C) `router.refresh()` após criar. +Recomendação: **A** — o assessor criado vira opção selecionável em QUALQUER linha no mesmo carregamento de página (decisão travada no gate); mesmo padrão do `CampaignListSheetProvider` já existente; contexto atravessa componentes server entre provider e células client. +Rejeitadas: B — só a linha criadora conhece o novo assessor, fura o aceite quando o coordenador abre o popover de outro município; C — refresh RSC no meio de delta otimista pode clobber estado em voo e diverge do contrato B27 "reconcilia na próxima navegação" (a página é dinâmica; a navegação seguinte já re-query o `getEligibleAdvisorOptions`). + +**D5 — Chip otimista.** +Opções: A) id temporário NEGATIVO no `selectedIDs` (disjunto dos ids reais) + mesmo maquinário seq/`pendingCountRef`/`latestConfirmedRef`; X oculto no chip temporário | B) aguardar a resposta e só então adicionar o chip real. +Recomendação: **A** — "chip aparece imediatamente (estado otimista)" é aceite explícito; o settle reusa `finishRequest` (adota o set confirmado com o id real quando 0 pendências); falha reverte o temp id como o toggle reverte. X oculto no temp: não há como cancelar um create server-side a meio voo; remoção real após o settle. Guard `advisorId < 0` no `toggle` (belt-and-suspenders: id temporário nunca vira POST). +Rejeitadas: B — o chip demora um round trip (~150 ms) contra o instantâneo dos toggles; o aceite pede otimista. + +**D6 — Schema do nome.** +Opções: A) `z.string().trim().min(2).max(160)` inline em `schemas/municipality.ts` | B) extrair `advisorNameSchema` em `schemas/advisor.ts` e importar. +Recomendação: **A** — `schemas/advisor.ts` já importa de `schemas/municipality.ts` (`MAX_ADVISORS_PER_MUNICIPALITY`…); extrair criaria ciclo de import (madge `check:cycles` é gate). A regra tem 1 linha; a divergência futura seria intencional por tela. +Rejeitadas: B — ciclo. + +### Componentes / mudanças + +- **`stubCampaignUserEmailFor`** (`src/lib/schemas/advisor.ts`): `slugify(name)` + sufixo `@criado.invalid`, com `-{occurrence}` a partir da 2ª ocorrência. Ao lado de `planilhaPlaceholderEmailForAdvisor` (mesmo módulo de e-mails stub). Predicado de placeholder generalizado (cobre os dois sufixos, case-insensitive) — mesmo nome de export, doc comentada. +- **`municipalityAdvisorCreateSchema`** (`src/lib/schemas/municipality.ts`): `{ municipality: positiveRelationshipId, name: z.string().trim().min(2).max(160) }` + tipo `MunicipalityAdvisorCreateInput`. +- **`createMunicipalityAdvisorRecord` / `createMunicipalityAdvisor`** (`src/app/(campaign)/campanha/actions/municipality.ts`): transação — `reloadUnrestrictedActor` (mensagem de membership) → lock → probe de stub livre (loop bounded por `equals` com `overrideAccess: true` + `req`) → `payload.create` (`campaignUser`, `user: currentActor`, `overrideAccess: false`, `req`; dados `hookFilledCreateData<'campaignUser'>` com nome + `role: 'advisor'` + stub + `password: randomBytes(24).toString('base64url')`) → `findByID` atual → `nextAdvisorIdsAfterMembership(current, created.id, true)` (cap → throw → rollback sem órfão) → `payload.update` (`overrideAccess: true` + `req`, comentário de bypass como no toggle). Retorna `{ advisors, createdAdvisorId }`. Export não revalida (contrato do endpoint). +- **`route.ts`** (`src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts`): `bodySchema` vira union `toggle | create`; handler ramifica; resposta do create `{ status: 'success', message: 'Assessor criado e atribuído.', advisors, createdAdvisor: { id, name } }`. `safeMessages` inalterado (cap + unrestricted já estão lá). +- **`types.ts`**: `MunicipalityListAdvisorsResponse` success ganha `createdAdvisor?: { id: number; name: string } | null`. +- **`MunicipalityAdvisorCreateProvider.tsx`** (novo, client, `src/components/campaign/municipality/`): contexto `{ createdOptions: EligibleAdvisorOption[], registerCreatedAdvisor }`; registro deduplica por id. Padrão `CampaignListSheetProvider`. +- **`MunicipalityList.tsx`** (server): envolve `MunicipalityListMobileSection` + `CampaignTable` no provider (children server — padrão de client provider com slots server). +- **`MunicipalityListAdvisorsControl.tsx`**: quando `filteredOptions.length === 0` — se `query.trim().length >= 2`, um `CommandItem` "Criar assessor ''" (`UserPlusIcon`) ANTES do "Nenhum resultado"; senão, "Nenhum resultado" como hoje. `createAdvisor(name)`: `setErrorMessage(null)`, temp id `-seq` em `selectedIDs`, `pendingCreates` (Map tempId→nome) no lookup do memo, `setQuery('')`, maquinário de pendência/seq; POST `{ municipalityId, name }`; sucesso → `latestConfirmedRef` (se seq mais novo) + `registerCreatedAdvisor({ id, name, isCurrent: false })` + remove temp do `pendingCreates`; falha → remove temp de `selectedIDs`/`pendingCreates` + `reportFailure`. `effectiveOptions = [...options, ...createdOptions]` (do provider) alimenta o filtro e o lookup. Chip temporário renderiza sem `XIcon` (indisponível enquanto pendente); `toggle` recusa `advisorId < 0`. +- **Migration:** nenhuma (campos existentes). +- **Access / Consent:** nenhum novo — create passa por `canManageCampaignUsers` (`overrideAccess: false` + `user`); sem `Consent` (conta interna de staff, precedente B19). + +### Dados → forma + +N/A — affordance de **escrita** sobre `municipality.advisors` via criação de `campaignUser`; nenhuma métrica, série, ranking ou mapa novo (a intenção já declara `Dados: N/A`). + +## Fases verificáveis + +1. **Schema/server** (maior parte do appetite): helpers puros + schemas → action record → route/types. Unit dos helpers; int do record. +2. **UI**: provider + control (opção de criar, temp chip, registro no provider). Unit de componente (render da opção sem fetch). +3. **Gates**: `pnpm gate:fast` na iteração; e2e do fluxo completo; `pnpm push` no fechamento (com o `*-impl.md` no commit). + +### Testes + +- **Unit:** `stubCampaignUserEmailFor` (slug sem acento via `slugify`, `-2` na 2ª ocorrência); predicado generalizado (`@planilha.invalid` e `@criado.invalid`, case-insensitive); parse da union do route (toggle sem `name` → caminho antigo; create sem `advisorId` → caminho novo; shapes inválidos 400). +- **Int** (`tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts` ou spec irmão): coordinator/candidate criam + atribuem (conta criada com `role: 'advisor'` + stub `@criado.invalid`, município contém o id); advisor/leader negados; cap 10 → rejeita e **não deixa órfão** (contagem de `campaignUser` inalterada após o erro); mesmo nome duas vezes → segundo com stub `-2`. +- **Unit componente** (`tests/unit/campaignCellEditOverlay.unit.spec.ts` / `campaignComponents.unit.spec.ts`): options vazias + query ≥ 2 → item "Criar assessor" presente; query < 2 → "Nenhum resultado". +- **E2E** (`tests/e2e/campaignMunicipalities.e2e.spec.ts`): abrir popover, digitar nome sem match, Enter, chip aparece, reabrir → persistiu. A rota já está no prewarm de `setup.e2e.spec.ts` (mesmo path do B27). + +## Rabbit holes / Não escopo (engenharia) + +- Formulário completo inline, e-mail/celular/senha/foto no popover → `/campanha/assessores/[id]`. +- Dedup por similaridade de nome ("Maria" vs "Maria Silva") → fora do appetite, coordenador verifica (decisão de produto). +- Faceta `?advisor=` e ordenação em tempo real → contrato B24/B27. +- Revalidação de `/campanha/assessores` após criar → página dinâmica; reconcilia na próxima navegação. +- Temp id **nunca** pode vazar num POST: guard `advisorId < 0` no `toggle` + X oculto no chip temporário. +- Probe de stub: `equals` em loop bounded, **não** `like` (semântica de curinga em e-mail). + +## Riscos e mitigação + +- **Interação temp chip × reconcile existente:** temp id negativo é disjunto dos ids reais; `finishRequest` adota o set confirmado (id real) quando 0 pendências — o chip nunca duplica nem some antes da hora. +- **Create dentro de transação + access:** `canManageCampaignUsers` → `getFreshCampaignUser(req)` precisa que o `user` seja casado no `req` da transação — mesmo padrão já em produção no `createActivityRecord` (`user` + `overrideAccess: false` + `req`); se quebrar, falha fechada (403), não fail-open. Int tests com coordinator/candidate cobrem o caminho feliz. +- **Colisão de stub rara mas real:** probe determinística na transação; fallback (unique violation inesperado) colapsa no `genericMessage` — aceitável. +- **Flakiness E2E pré-existente sob carga:** rodar o spec isolado na validação final (padrão do time). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: cria `advisor` só com nome no popover; atribuição automática; chip otimista; erro no popover (Alert existente); sem login até credenciais reais (stub `@criado.invalid` + senha aleatória, reset bloqueado como placeholder); `leader` intocado; faceta reconcilia na próxima navegação +- [ ] Invariantes AGENTS/engineering-standards: transação + `req` em escrita multi-collection; `overrideAccess: false` com `user` no create; identificadores em inglês / strings pt-BR; sem migration; sem `Consent` novo +- [ ] Testes de domínio: unit (stub/predicado/union), int (roles, cap com rollback sem órfão, dedup), unit componente, e2e +- [ ] Gates: `tsc --noEmit`, `lint --max-warnings=0`, `format:check`, `check:cycles` (madge), `knip`, `pnpm test`, `pnpm build` local (nunca Neon) + +Self-score decision-quality: **5/5** — decisões caras com rejeitadas registradas (D1–D6); cabe no appetite (~0,5 dia); rabbit holes nomeados; depth check reusa shells (transação, locks, `nextAdvisorIdsAfterMembership`, `campaignJsonMutationRoute`, `Command`, `useCampaignCellFailureChannel`, `CampaignListSheetProvider` como padrão de provider); aceite de produto preservado. + +## Simplify (2026-08-03) — aplicado e deferido + +Três revisores paralelos (qualidade, reuso/DRY, comportamento) no diff do B154; zero P1. Aplicado no cleanup (comportamento preservado, testado): + +- **`effectiveOptions` dedup por id** — o provider sobrevive a re-renders server (navegação de sort/filtro re-executa `getEligibleAdvisorOptions`), então o mesmo assessor podia aparecer duas vezes na lista de opções; dedup no merge. +- **Guard de create em voo por nome** — create não é delta idempotente; re-ativar o mesmo nome com o primeiro em voo criaria uma 2ª conta. Skip se o nome já está em `pendingCreates` (o temp chip já sinaliza). +- **`finishRequest` compartilhado** — o closure de settle era byte-idêntico entre toggle e create; hoisted uma vez no corpo do componente. +- **`advisorNameSchema` em `primitives.ts`** — regra do nome espelhada em 3 pontos (route, schema B154, `advisorCreateSchema` do B19); módulo folha sem ciclo (madge verde). +- **Fallback de slug vazio no stub** — nome só de pontuação (ex. "!!!") passa `min(2)` mas slugifica para `""` → e-mail inválido e erro genérico sem saída; cai para `assessor@criado.invalid` + unit test. +- **`trimmedQuery` hoisted + gate `<= 160`** no item "Criar assessor" (espelha o contrato do servidor). +- **`createdAdvisor` sem `| null`** — a rota nunca envia `null`. + +**Defer com gatilho (não registrar como Issue):** + +- Probe de stub não-atômico entre municípios (dois coordenadores criando o mesmo nome simultaneamente em municípios diferentes): rollback gracioso + erro genérico. **Gatilho:** evidência real de colisão (queixa/erro observado). +- Reconcile assume commit na ordem de envio (machinery-wide do B27, toggle compartilha): um interleave raro pode dropar o chip do create até a próxima navegação. **Gatilho:** chip sumindo observado em produção. + +**Descartado:** renomear `isPlanilhaPlaceholderEmail` (drive-by de 4 call sites pré-existentes); alias table do E4R no stub (TLDs distintos, sem colisão); `payload-types.ts` com drift do B134 (regenerado por cada run e2e — pertence à entrega do B134). diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts index 871fb6a7..87188ff9 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts @@ -1,10 +1,13 @@ import { NextResponse } from 'next/server' import { z } from 'zod' -import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality' +import { + createMunicipalityAdvisor, + setMunicipalityAdvisorMembership, +} from '@/app/(campaign)/campanha/actions/municipality' import { uniqueRelationshipIds } from '@/lib/relationship' import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality' -import { positiveRelationshipId } from '@/lib/schemas/primitives' +import { advisorNameSchema, positiveRelationshipId } from '@/lib/schemas/primitives' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' import type { MunicipalityListAdvisorsResponse } from './types' @@ -13,11 +16,22 @@ export type { MunicipalityListAdvisorsResponse } from './types' export const dynamic = 'force-dynamic' -const bodySchema = z.object({ - municipalityId: positiveRelationshipId, - advisorId: positiveRelationshipId, - assigned: z.boolean(), -}) +/** + * Mutually exclusive shapes (B154): the B27 toggle (`advisorId` + `assigned`) + * and the name-only create (`name`) can never combine — `name` is a write + * affordance over `municipality.advisors`, `advisorId` a membership delta. + */ +const bodySchema = z.union([ + z.strictObject({ + municipalityId: positiveRelationshipId, + advisorId: positiveRelationshipId, + assigned: z.boolean(), + }), + z.strictObject({ + municipalityId: positiveRelationshipId, + name: advisorNameSchema, + }), +]) export const POST = campaignJsonMutationRoute( { @@ -26,16 +40,30 @@ export const POST = campaignJsonMutationRoute( genericMessage: 'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.', }, - async ({ municipalityId, advisorId, assigned }) => { + async (body) => { + if ('name' in body) { + const updated = await createMunicipalityAdvisor({ + municipality: body.municipalityId, + name: body.name, + }) + + return NextResponse.json({ + status: 'success', + message: 'Assessor criado e atribuído.', + advisors: uniqueRelationshipIds(updated.advisors), + createdAdvisor: { id: updated.createdAdvisorId, name: body.name }, + }) + } + const updated = await setMunicipalityAdvisorMembership({ - municipality: municipalityId, - advisor: advisorId, - assigned, + municipality: body.municipalityId, + advisor: body.advisorId, + assigned: body.assigned, }) return NextResponse.json({ status: 'success', - message: assigned ? 'Assessor atribuído.' : 'Assessor removido.', + message: body.assigned ? 'Assessor atribuído.' : 'Assessor removido.', advisors: uniqueRelationshipIds(updated.advisors), }) }, diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts index 90856c4a..6810d9b8 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/types.ts @@ -3,6 +3,8 @@ export type MunicipalityListAdvisorsResponse = status: 'success' message: string advisors: number[] + /** B154 — present only on the name-only create path; the client registers it locally. */ + createdAdvisor?: { id: number; name: string } } | { status: 'error' diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index 79e8c596..aa57f4cd 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -1,5 +1,7 @@ 'use server' +import { randomBytes } from 'node:crypto' + import type { Payload } from 'payload' import { @@ -9,15 +11,19 @@ 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, municipalityAdvisorsAssignmentSchema, municipalityEngagementLevelSchema, municipalityExpectedVotesSchema, municipalityPoliticalTrendSchema, municipalityStrategyUpdateSchema, + type MunicipalityAdvisorCreateInput, type MunicipalityAdvisorMembershipInput, type MunicipalityAdvisorsAssignmentInput, type MunicipalityEngagementLevelInput, @@ -32,6 +38,7 @@ import { reloadStaffActor, reloadUnrestrictedActor, } from '@/utilities/campaignActionContext' +import { hookFilledCreateData } from '@/utilities/hookFilledData' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' @@ -337,3 +344,125 @@ export const setMunicipalityAdvisorMembership = async ( const { payload, actor } = await getCampaignActionContext() return setMunicipalityAdvisorMembershipRecord(payload, actor, input) } + +/** + * B154 — name-only advisor create from the list popover. Picks the next free + * `@criado.invalid` stub (exact-match probes on the unique e-mail index; + * `campaignUser.name` is not unique, so two identically-slugged names get + * `-N` suffixes), then creates the account and assigns it to the município in + * the SAME transaction: if the 10-advisor cap throws, the account create rolls + * back with it — no orphan. The random password is never shared, so the account + * cannot log in until a coordinator swaps in real credentials in + * `/campanha/assessores/[id]` — same contract as the E4R seed placeholders. + */ +export const createMunicipalityAdvisorRecord = async ( + payload: Payload, + actor: CampaignUser, + input: MunicipalityAdvisorCreateInput, +) => { + const { municipality, name } = municipalityAdvisorCreateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadUnrestrictedActor( + payload, + actor, + MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE, + req, + ) + + 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`. + const created = await payload.create({ + collection: 'campaignUser', + data: hookFilledCreateData<'campaignUser'>({ + name, + role: 'advisor', + email, + password: randomBytes(24).toString('base64url'), + }), + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + const current = await payload.findByID({ + collection: 'municipality', + id: municipality, + depth: 0, + select: { advisors: true }, + // Intentional admin bypass: the unrestricted role was freshly verified + // above; the advisors field is admin-only by field access. + overrideAccess: true, + req, + }) + + const nextAdvisorIDs = nextAdvisorIdsAfterMembership( + uniqueRelationshipIds(current.advisors), + created.id, + true, + ) + // Unreachable — the created id cannot already be assigned — but mirrors + // the toggle's no-write return so the null branch is honest. + if (nextAdvisorIDs === null) { + return { advisors: current.advisors, createdAdvisorId: created.id } + } + + // Intentional admin bypass: unrestricted role was freshly verified above; + // the advisors field is admin-only by field access, and eligibility is + // reconfirmed by the `validateMunicipalityAdvisors` beforeValidate hook. + const updated = await payload.update({ + collection: 'municipality', + id: municipality, + data: { advisors: nextAdvisorIDs }, + depth: 0, + overrideAccess: true, + req, + }) + + return { advisors: updated.advisors, createdAdvisorId: created.id } + }, + { beginFailureMessage: 'Não foi possível iniciar a criação do assessor.' }, + ) +} + +export const createMunicipalityAdvisor = async (input: MunicipalityAdvisorCreateInput) => { + const { payload, actor } = await getCampaignActionContext() + return createMunicipalityAdvisorRecord(payload, actor, input) +} + +/** + * Next free `@criado.invalid` stub: bounded exact-match probes on the + * unique `email` index (no LIKE semantics). Collisions only happen when two + * names slugify identically, so the first probe usually succeeds. The read + * bypasses access control on purpose: the transaction already verified the + * unrestricted role, and the probe only looks at placeholder e-mails. + */ +const nextFreeStubCampaignEmail = async ( + payload: Payload, + req: PayloadTransactionRequest, + name: string, +): Promise => { + for (let occurrence = 1; occurrence <= 50; occurrence += 1) { + const candidate = stubCampaignUserEmailFor(name, occurrence) + const existing = await payload.find({ + collection: 'campaignUser', + where: { email: { equals: candidate } }, + depth: 0, + limit: 1, + select: { email: true }, + // Intentional admin bypass: unrestricted role verified above in the + // transaction; the probe only reads placeholder e-mails. + overrideAccess: true, + req, + }) + if (existing.totalDocs === 0) return candidate + } + throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE) +} diff --git a/src/components/campaign/municipality/MunicipalityAdvisorCreateProvider.tsx b/src/components/campaign/municipality/MunicipalityAdvisorCreateProvider.tsx new file mode 100644 index 00000000..6776cfe4 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityAdvisorCreateProvider.tsx @@ -0,0 +1,46 @@ +'use client' + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react' + +import type { EligibleAdvisorOption } from '@/utilities/municipality/municipalityViewModels' + +type MunicipalityAdvisorCreateContextValue = { + /** Advisors created inline (B154) since the page last rendered server-side. */ + createdOptions: EligibleAdvisorOption[] + registerCreatedAdvisor: (option: EligibleAdvisorOption) => void +} + +const MunicipalityAdvisorCreateContext = + createContext(null) + +export const useMunicipalityAdvisorCreate = () => useContext(MunicipalityAdvisorCreateContext) + +/** + * B154 — one shared bridge of inline-created advisor options for the whole + * `/campanha/municipios` list surface (desktop table + mobile cards): a create + * in one row's popover makes the new account selectable in every other row's + * popover on the same page load, without waiting for an RSC refresh. The bridge + * is short-lived by design — the page is dynamic, so the next navigation's + * `getEligibleAdvisorOptions` already returns the account from the DB. Same + * shell pattern as `CampaignListSheetProvider`. + */ +export const MunicipalityAdvisorCreateProvider = ({ children }: { children: ReactNode }) => { + const [createdOptions, setCreatedOptions] = useState([]) + + const registerCreatedAdvisor = useCallback((option: EligibleAdvisorOption) => { + setCreatedOptions((current) => + current.some((existing) => existing.id === option.id) ? current : [...current, option], + ) + }, []) + + const contextValue = useMemo( + () => ({ createdOptions, registerCreatedAdvisor }), + [createdOptions, registerCreatedAdvisor], + ) + + return ( + + {children} + + ) +} diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx index 8fb33f37..d9745b99 100644 --- a/src/components/campaign/municipality/MunicipalityFilters.tsx +++ b/src/components/campaign/municipality/MunicipalityFilters.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState, type ReactNode } from 'react' +import { Fragment, useMemo, useState, type ReactNode } from 'react' import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl' @@ -136,12 +136,14 @@ export const MunicipalityFilters = ({ onClearAll={() => { runAction(clearMunicipalityOmnibox(state)) }} - trailing={ - <> - {trailing} - - - } + trailing={[ + // React reconciles unkeyed fragments' children as a keyed list — a + // bare `<> {trailing} ` logs a key warning on every + // municipios render (and fails the e2e console guard). Keyed array + // children keep the two controls side by side without the warning. + {trailing}, + , + ]} /> ) diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 15ea0250..86b263ca 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -6,6 +6,7 @@ import { formatAdvisorNamesTooltip, MunicipalityAdvisorAvatarStack, } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack' +import { MunicipalityAdvisorCreateProvider } from '@/components/campaign/municipality/MunicipalityAdvisorCreateProvider' import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge' import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl' import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl' @@ -450,7 +451,7 @@ export const MunicipalityList = (props: MunicipalityListProps) => { ).some((column) => column.id === 'votos') return ( - <> + { rowKey={(municipality) => municipality.id} empty={} /> - + ) } diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index b6c254d7..481eceef 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -1,6 +1,6 @@ 'use client' -import { XIcon } from 'lucide-react' +import { UserPlusIcon, XIcon } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types' @@ -9,6 +9,7 @@ import { formatAdvisorNamesTooltip, MunicipalityAdvisorAvatarStack, } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack' +import { useMunicipalityAdvisorCreate } from '@/components/campaign/municipality/MunicipalityAdvisorCreateProvider' import { CampaignCellEditOverlay, type CampaignCellEditOverlayVariant, @@ -30,6 +31,14 @@ import type { const ADVISORS_ENDPOINT = '/campanha/municipios/advisors' const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.' +/** B154 — sentinel temp id for an in-flight inline create; negative = never a real account. */ +const isPendingCreateId = (id: number): boolean => id < 0 + +// Shared empty values so the memos stay referentially stable when the bridge +// is absent (standalone renders in unit tests) or nothing is pending. +const EMPTY_CREATED_OPTIONS: EligibleAdvisorOption[] = [] +const EMPTY_PENDING_CREATES: ReadonlyMap = new Map() + type MunicipalityListAdvisorsControlProps = { municipalityID: number municipalityName: string @@ -84,17 +93,41 @@ export const MunicipalityListAdvisorsControl = ({ setSelectedIDs(currentAdvisorIDs) }, [currentAdvisorIDs]) + // B154 — advisors created inline since the page last rendered server-side, + // shared across every row's popover through `MunicipalityAdvisorCreateProvider` + // (null when the control renders standalone, e.g. in unit tests). + const createBridge = useMunicipalityAdvisorCreate() + const createdOptions = createBridge?.createdOptions ?? EMPTY_CREATED_OPTIONS + const registerCreatedAdvisor = createBridge?.registerCreatedAdvisor + // tempId → name for in-flight creates: the chip renders optimistically while + // the request is out, and the id is not known until the response arrives. + const [pendingCreates, setPendingCreates] = + useState>(EMPTY_PENDING_CREATES) + // Every eligible account (coordinator/advisor/candidate) is listed here // regardless of current assignment, so it doubles as the name lookup for an // id the popover just optimistically added — no round trip needed to label // it. `advisorNamesById` is the fallback for an id that fell out of // eligibility but is still assigned; `advisorEntriesFromIds` drops the rare // id found in neither, same as every other advisor cell in the list. + const effectiveOptions = useMemo(() => { + // The provider bridge can outlive a server re-render (sort/filter + // navigation keeps client state, and `getEligibleAdvisorOptions` then + // returns the created account again), so the same advisor can arrive + // twice — dedupe by id to keep combobox entries and React keys unique. + const byId = new Map() + for (const option of options) byId.set(option.id, option) + for (const option of createdOptions) byId.set(option.id, option) + return [...byId.values()] + }, [options, createdOptions]) + const advisorLookup = useMemo(() => { const lookup = new Map(advisorNamesById) - for (const option of options) lookup.set(option.id, { id: option.id, name: option.name }) + for (const option of effectiveOptions) + lookup.set(option.id, { id: option.id, name: option.name }) + for (const [tempId, name] of pendingCreates) lookup.set(tempId, { id: tempId, name }) return lookup - }, [advisorNamesById, options]) + }, [advisorNamesById, effectiveOptions, pendingCreates]) const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]) const chips = useMemo( @@ -116,11 +149,26 @@ export const MunicipalityListAdvisorsControl = ({ : 'sem assessor' const filteredOptions = useMemo( - () => options.filter((option) => matchesAtWordStart(option.name, query)), - [options, query], + () => effectiveOptions.filter((option) => matchesAtWordStart(option.name, query)), + [effectiveOptions, query], ) + // Single settle point shared by the toggle and the inline create (success, + // mapped error, network failure): decrements the pending count and, once it + // reaches 0, stops the spinner and reconciles to the latest confirmed server + // set — even on a request's own failure, an earlier delta in the same batch + // may have already confirmed one. + const finishRequest = () => { + pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) + if (pendingCountRef.current > 0) return + setIsPending(false) + if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.advisors) + } + const toggle = (advisorId: number, assigned: boolean) => { + // A negative id is a B154 pending-create sentinel, never a real account — + // the temp chip has no remove affordance, so this only guards the path. + if (isPendingCreateId(advisorId)) return setErrorMessage(null) setSelectedIDs((current) => { const next = new Set(current) @@ -144,18 +192,6 @@ export const MunicipalityListAdvisorsControl = ({ }) } - // Single settle point for every exit path (success, mapped error, - // network failure): decrements the pending count and, once it reaches - // 0, stops the spinner and reconciles to the latest confirmed server - // set — even on this request's own failure, an earlier delta in the - // same batch may have already confirmed one. - const finishRequest = () => { - pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) - if (pendingCountRef.current > 0) return - setIsPending(false) - if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.advisors) - } - try { const { ok, payload } = await postCampaignJson( ADVISORS_ENDPOINT, @@ -181,11 +217,92 @@ export const MunicipalityListAdvisorsControl = ({ })() } + /** + * B154 — name-only inline create, assigned to this município. The chip goes + * up immediately under a sentinel temp id (the real id only exists once the + * response lands); the request rides the same seq/pending machinery as the + * toggle, so out-of-order settles and the cap failure reconcile exactly like + * a toggle — a failed create reverts the temp chip and shows the Alert. + */ + const createAdvisor = (name: string) => { + // A create is not an idempotent delta: re-activating the same name while a + // create is in flight would mint a second account. The temp chip already + // signals the pending one — skip. + if ([...pendingCreates.values()].includes(name)) return + setErrorMessage(null) + setQuery('') + + pendingCountRef.current += 1 + setIsPending(true) + const requestSeq = (requestSeqRef.current += 1) + const tempId = -requestSeq + + setSelectedIDs((current) => [...current, tempId]) + setPendingCreates((current) => new Map(current).set(tempId, name)) + + void (async () => { + const revertCreate = () => { + setSelectedIDs((current) => current.filter((id) => id !== tempId)) + setPendingCreates((current) => { + const next = new Map(current) + next.delete(tempId) + return next + }) + } + + try { + const { ok, payload } = await postCampaignJson( + ADVISORS_ENDPOINT, + { municipalityId: municipalityID, name }, + ) + + if (!ok || payload.status !== 'success') { + revertCreate() + reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) + finishRequest() + return + } + + if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) { + latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors } + } + + const createdAdvisor = payload.createdAdvisor + if (createdAdvisor) { + // Swap the sentinel for the real id right away: if another delta is + // still in flight the reconcile is deferred, and the chip must not + // vanish in the gap (the lookup drops a temp id the moment its + // pending entry is removed). + setSelectedIDs((current) => [...current.filter((id) => id !== tempId), createdAdvisor.id]) + setPendingCreates((current) => { + const next = new Map(current) + next.delete(tempId) + return next + }) + // Make the account selectable in every other row's popover on this + // page load — `createdOptions` is the client-side bridge until the + // next navigation's `getEligibleAdvisorOptions` picks it up. + registerCreatedAdvisor?.({ + id: createdAdvisor.id, + name: createdAdvisor.name, + isCurrent: false, + }) + } + finishRequest() + } catch { + revertCreate() + reportFailure(SAVE_ERROR_MESSAGE) + finishRequest() + } + })() + } + const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : '' // The body pads its own sections (`px-4`, aligned with the Drawer header) so // the `Command` can stay full-bleed in both containers — hence `px-0` on the // sheet body and `p-0` on the popover content. const isSheet = variant === 'sheet' + const trimmedQuery = query.trim() return ( {chips.length > 0 ? (
- {chips.map((chip) => ( - - ))} + ) : ( + + ), + )}
) : null} @@ -254,7 +384,20 @@ export const MunicipalityListAdvisorsControl = ({ /> {filteredOptions.length === 0 ? ( -

Nenhum resultado.

+ trimmedQuery.length >= 2 && trimmedQuery.length <= 160 ? ( + createAdvisor(trimmedQuery)} + > + + ) : ( +

+ Nenhum resultado. +

+ ) ) : ( filteredOptions.map((option) => { const assigned = selectedSet.has(option.id) diff --git a/src/lib/schemas/advisor.ts b/src/lib/schemas/advisor.ts index 37b6e91f..64f29521 100644 --- a/src/lib/schemas/advisor.ts +++ b/src/lib/schemas/advisor.ts @@ -5,21 +5,44 @@ import { MUNICIPALITY_ADVISORS_CAP_MESSAGE, } from '@/lib/schemas/municipality' import { + advisorNameSchema, brazilianMobile, optionalPersistedEmail, positiveRelationshipId, trimmedOptionalText, } from '@/lib/schemas/primitives' +import { slugify } from '@/lib/slug' const PLANILHA_INVALID_EMAIL_SUFFIX = '@planilha.invalid' +const INLINE_STUB_EMAIL_SUFFIX = '@criado.invalid' +/** + * Seed (`@planilha.invalid`, E4R) and inline-created (`@criado.invalid`, B154) + * stub emails — non-routable placeholders that mark a not-yet-activated + * account. Shared by the password-reset guard (`PLACEHOLDER_RESET_MESSAGE`) and + * the advisors list e-mail cell, so an inline-created account behaves exactly + * like a seeded one until a coordinator swaps in real credentials. + */ export const isPlanilhaPlaceholderEmail = (email: string | null | undefined): boolean => - typeof email === 'string' && email.trim().toLowerCase().endsWith(PLANILHA_INVALID_EMAIL_SUFFIX) + typeof email === 'string' && + (email.trim().toLowerCase().endsWith(PLANILHA_INVALID_EMAIL_SUFFIX) || + email.trim().toLowerCase().endsWith(INLINE_STUB_EMAIL_SUFFIX)) /** Stable seed-style placeholder used when the e-mail field is cleared in the list UI. */ export const planilhaPlaceholderEmailForAdvisor = (advisorId: number): string => `assessor-${advisorId}${PLANILHA_INVALID_EMAIL_SUFFIX}` +/** + * Inline-create stub (B154): `@criado.invalid`, `-N` from the + * second occurrence — `campaignUser.email` is unique while `name` is not, so + * two advisors whose names slugify identically get distinct stubs. Same + * deterministic pattern as the E4R seed; `name` is only the input to `slugify`. + */ +export const stubCampaignUserEmailFor = (name: string, occurrence = 1): string => { + const localPart = slugify(name) || 'assessor' + return `${localPart}${occurrence > 1 ? `-${occurrence}` : ''}${INLINE_STUB_EMAIL_SUFFIX}` +} + const advisorPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional() const optionalAdvisorPhone = advisorPhoneInput.transform((value) => @@ -53,7 +76,7 @@ export const ADVISOR_ACTION_SAFE_MESSAGES = [ ] as const export const advisorCreateSchema = z.object({ - name: z.string().trim().min(2).max(160), + name: advisorNameSchema, email: z.email('E-mail inválido.'), phone: optionalAdvisorPhone, }) diff --git a/src/lib/schemas/municipality.ts b/src/lib/schemas/municipality.ts index c67c056b..c60c0c4c 100644 --- a/src/lib/schemas/municipality.ts +++ b/src/lib/schemas/municipality.ts @@ -1,7 +1,11 @@ import { z } from 'zod' import { ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH, engagementLevels } from '@/lib/engagementLevel' -import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primitives' +import { + advisorNameSchema, + positiveRelationshipId, + trimmedNullableText, +} from '@/lib/schemas/primitives' import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge' export const politicalTrendStatuses = ['favoravel', 'neutra', 'desfavoravel'] as const @@ -77,6 +81,18 @@ export const municipalityAdvisorMembershipSchema = z.object({ assigned: z.boolean(), }) +/** + * B154 — inline create from the list popover: name-only account (`advisor` + * role, stub e-mail) assigned to the município in the same write. The name + * rule is the shared `advisorNameSchema` from the leaf primitives module — + * `schemas/advisor.ts` already imports from this one, so the reverse import + * would close a cycle. + */ +export const municipalityAdvisorCreateSchema = z.object({ + municipality: positiveRelationshipId, + name: advisorNameSchema, +}) + export const MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE = 'Somente a coordenação geral ou o candidato designa assessores.' @@ -85,6 +101,15 @@ export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [ MUNICIPALITY_ADVISORS_CAP_MESSAGE, ] as const +/** + * B154 — internal failure of the deterministic stub probe (the bounded loop + * found no free `@criado.invalid` e-mail). Not safelisted: it collapses + * to the route's generic message, exactly as an unexpected unique violation + * would. + */ +export const MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE = + 'Não foi possível gerar um e-mail placeholder único para o assessor.' + export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE = 'Somente a coordenação geral ou o candidato move o nível de envolvimento.' @@ -113,5 +138,6 @@ export type MunicipalityAdvisorsAssignmentInput = z.input< typeof municipalityAdvisorsAssignmentSchema > export type MunicipalityAdvisorMembershipInput = z.input +export type MunicipalityAdvisorCreateInput = z.input export type MunicipalityExpectedVotesInput = z.input export type MunicipalityEngagementLevelInput = z.input diff --git a/src/lib/schemas/primitives.ts b/src/lib/schemas/primitives.ts index b8ed421a..be65dd6b 100644 --- a/src/lib/schemas/primitives.ts +++ b/src/lib/schemas/primitives.ts @@ -2,6 +2,14 @@ import { z } from 'zod' import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone' +/** + * The assessor display-name rule, shared by the B19 account screen, the B154 + * inline create and the wire contract of `POST /campanha/municipios/advisors`. + * Owned here (a leaf module) because `schemas/advisor.ts` already imports from + * `schemas/municipality.ts`, so the reverse import would close a cycle. + */ +export const advisorNameSchema = z.string().trim().min(2).max(160) + export const positiveRelationshipId = z.number().int().positive() export const trimmedOptionalText = (maximum: number) => diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 7fc2fb85..aa133525 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -155,6 +155,76 @@ test.describe('Municípios — jornadas por papel', () => { ).toBeVisible() }) + test('coordinator creates an advisor inline in the list combobox (B154)', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenador Inline'), + }) + const password = coordinator.password + const municipality = await fixtures.claimMunicipality() + // Unique per run (uuid + counter), so no eligible account matches it — the + // combobox must offer the create option, not an existing advisor. + const newAdvisorName = fixtures.value('Novo Assessor') + + await campaign.login(page, coordinator.email!, password) + await page.goto( + `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, + ) + await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() + + const advisorsTrigger = page.getByRole('button', { + name: `Editar assessores em ${municipality.name}`, + }) + await advisorsTrigger.click() + const advisorsPopover = page.locator('[data-slot="popover-content"]') + await expect(advisorsPopover).toBeVisible() + + const search = advisorsPopover.getByRole('combobox', { name: 'Buscar assessor' }) + const createOption = advisorsPopover.getByRole('option', { + name: new RegExp(`^Criar assessor.*${newAdvisorName}`), + }) + // The removable chip only exists once the response lands — the optimistic + // temp chip has no remove affordance. `count()` guard: once the create + // succeeded and registered the account, the create option is gone, so a + // retry must not try to click it again (it would time out) — skipping the + // click and waiting for the chip is what a post-success retry does. + const chip = advisorsPopover.getByRole('button', { name: `Remover ${newAdvisorName}` }) + + await expect(async () => { + await search.fill(newAdvisorName) + if (await createOption.count()) await createOption.click({ timeout: 1_000 }) + await expect(chip).toBeVisible({ timeout: 5_000 }) + }).toPass({ timeout: 25_000 }) + + // Same popover, no reload: typing the name now lists the account instead of + // offering to create it again — the local bridge registered it. + await search.fill(newAdvisorName) + await expect(advisorsPopover.getByRole('option', { name: newAdvisorName })).toBeVisible() + await expect(advisorsPopover.getByRole('option', { name: /Criar assessor/ })).toHaveCount(0) + + // The popover stays open after the write (auto-save, not submit+close). + await expect(advisorsPopover).toBeVisible() + await page.keyboard.press('Escape') + + // Persistence: reload and reopen to confirm the server holds the account + // and the assignment, not just the local optimistic state. + await page.reload() + await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() + await page + .getByRole('button', { + name: `Editar assessores em ${municipality.name} — ${newAdvisorName}`, + }) + .click() + await expect( + page.locator('[data-slot="popover-content"]').getByRole('button', { + name: `Remover ${newAdvisorName}`, + }), + ).toBeVisible() + }) + test('coordinator sets trend status and justification with auto-save (B24)', async ({ campaign, page, @@ -512,6 +582,13 @@ test.describe('Municípios — FAB overlay polish (B126)', () => { await page.getByRole('button', { name: 'Ações rápidas' }).click() const overlay = page.locator('#CampaignQuickActionsOverlay') await expect(overlay).toBeVisible() + // The Drawer slides in over ~450ms (`duration-450`); `toBeVisible` fires + // mid-flight, so measuring right away reads items at different slide + // offsets and the 3×2 grouping falls apart. Wait for the entrance + // animation to settle before collecting geometry. + await overlay.evaluate(async (el) => { + await Promise.all(el.getAnimations().map((animation) => animation.finished)) + }) const actionsRegion = overlay.getByLabel('Ações rápidas') await expect(actionsRegion.locator('ul[data-layout="grid-3"]')).toBeVisible() diff --git a/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts b/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts index 7e8309ab..a86c7e4f 100644 --- a/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts +++ b/tests/int/campaignMunicipalityAdvisorMembership.int.spec.ts @@ -4,7 +4,11 @@ import type { Payload } from 'payload' import { getPayload } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' -import { setMunicipalityAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/municipality' +import { + createMunicipalityAdvisorRecord, + setMunicipalityAdvisorMembershipRecord, +} from '@/app/(campaign)/campanha/actions/municipality' +import { stubCampaignUserEmailFor } from '@/lib/schemas/advisor' import config from '@/payload.config' import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures' @@ -139,3 +143,119 @@ describe('setMunicipalityAdvisorMembershipRecord (B27)', () => { ).rejects.toThrow(/no máximo 10 assessores/i) }) }) + +describe('createMunicipalityAdvisorRecord (B154)', () => { + const findAccount = async (id: number) => + payload.findByID({ collection: 'campaignUser', id, depth: 0, overrideAccess: true }) + + it.each(['coordinator', 'candidate'] as const)( + 'lets a %s create an advisor by name and assign it to the município', + async (role) => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser(role) + const municipality = await fixtures.getMunicipality() + const name = fixtures.value('Novo Assessor') + + const created = await createMunicipalityAdvisorRecord(payload, actor, { + municipality: municipality.id, + name, + }) + fixtures.touchMunicipality(municipality.id) + + expect(relationIds(created.advisors)).toContain(created.createdAdvisorId) + + const account = await findAccount(created.createdAdvisorId) + expect(account.role).toBe('advisor') + expect(account.email).toBe(stubCampaignUserEmailFor(name)) + // The random password is never shared and the stub e-mail cannot route + // (reset is blocked for placeholders), so the account cannot log in until + // a coordinator swaps in real credentials — same contract as E4R seeds. + }, + ) + + it('assigns a -N stub when a same-name account already has the first one', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const name = fixtures.value('Maria Silva') + + const first = await createMunicipalityAdvisorRecord(payload, coordinator, { + municipality: municipality.id, + name, + }) + const second = await createMunicipalityAdvisorRecord(payload, coordinator, { + municipality: municipality.id, + name, + }) + + expect(second.createdAdvisorId).not.toBe(first.createdAdvisorId) + // `campaignUser.name` is not unique — both accounts are legal, the stub + // e-mail is what gets the deterministic `-N`. + expect((await findAccount(first.createdAdvisorId)).email).toBe(stubCampaignUserEmailFor(name)) + expect((await findAccount(second.createdAdvisorId)).email).toBe( + stubCampaignUserEmailFor(name, 2), + ) + expect(relationIds(second.advisors)).toContain(second.createdAdvisorId) + }) + + it('denies advisor and leader actors without creating an account', async () => { + const fixtures = campaignFixtures() + const advisorActor = await fixtures.createCampaignUser('advisor') + const leaderActor = await fixtures.createCampaignUser('leader') + const municipality = await fixtures.getMunicipality() + const name = fixtures.value('Bloqueado') + + for (const actor of [advisorActor, leaderActor]) { + await expect( + createMunicipalityAdvisorRecord(payload, actor, { + municipality: municipality.id, + name, + }), + ).rejects.toThrow(/coordenação geral ou o candidato/i) + } + + const stub = stubCampaignUserEmailFor(name) + const existing = await payload.find({ + collection: 'campaignUser', + where: { email: { equals: stub } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + expect(existing.totalDocs).toBe(0) + }) + + it('rejects at the 10-advisor cap and rolls the account create back', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + + for (let index = 0; index < 10; index += 1) { + const advisor = await fixtures.createCampaignUser('advisor') + await setMunicipalityAdvisorMembershipRecord(payload, coordinator, { + municipality: municipality.id, + advisor: advisor.id, + assigned: true, + }) + } + fixtures.touchMunicipality(municipality.id) + + const name = fixtures.value('Cap Estourado') + await expect( + createMunicipalityAdvisorRecord(payload, coordinator, { + municipality: municipality.id, + name, + }), + ).rejects.toThrow(/no máximo 10 assessores/i) + + // Same transaction: the failed assignment rolled the account create back. + const orphan = await payload.find({ + collection: 'campaignUser', + where: { email: { equals: stubCampaignUserEmailFor(name) } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + expect(orphan.totalDocs).toBe(0) + }) +}) diff --git a/tests/unit/advisorSchema.unit.spec.ts b/tests/unit/advisorSchema.unit.spec.ts index 68a3c4c7..eb082b48 100644 --- a/tests/unit/advisorSchema.unit.spec.ts +++ b/tests/unit/advisorSchema.unit.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { isPlanilhaPlaceholderEmail, planilhaPlaceholderEmailForAdvisor, + stubCampaignUserEmailFor, } from '@/lib/schemas/advisor' describe('isPlanilhaPlaceholderEmail', () => { @@ -11,8 +12,14 @@ describe('isPlanilhaPlaceholderEmail', () => { expect(isPlanilhaPlaceholderEmail('Edizio@Planilha.Invalid')).toBe(true) }) + it('detects the B154 inline-create suffix case-insensitively', () => { + expect(isPlanilhaPlaceholderEmail('joao@criado.invalid')).toBe(true) + expect(isPlanilhaPlaceholderEmail('Maria-Santos-2@Criado.Invalid')).toBe(true) + }) + it('rejects real emails and empty values', () => { expect(isPlanilhaPlaceholderEmail('edizio@example.com')).toBe(false) + expect(isPlanilhaPlaceholderEmail('joao@criado.com.br')).toBe(false) expect(isPlanilhaPlaceholderEmail('')).toBe(false) expect(isPlanilhaPlaceholderEmail(null)).toBe(false) expect(isPlanilhaPlaceholderEmail(undefined)).toBe(false) @@ -24,3 +31,22 @@ describe('planilhaPlaceholderEmailForAdvisor', () => { expect(planilhaPlaceholderEmailForAdvisor(42)).toBe('assessor-42@planilha.invalid') }) }) + +describe('stubCampaignUserEmailFor', () => { + it('builds @criado.invalid from the normalized name', () => { + expect(stubCampaignUserEmailFor('Maria Silva')).toBe('maria-silva@criado.invalid') + expect(stubCampaignUserEmailFor('João José')).toBe('joao-jose@criado.invalid') + }) + + it('appends -N from the second occurrence', () => { + expect(stubCampaignUserEmailFor('Maria Silva', 2)).toBe('maria-silva-2@criado.invalid') + expect(stubCampaignUserEmailFor('Maria Silva', 3)).toBe('maria-silva-3@criado.invalid') + }) + + it('falls back to a non-empty local part when the name has no letters or digits', () => { + // "!!!" passes min(2) but slugifies to "" — the stub must still be a + // valid e-mail local part instead of "@criado.invalid" (which would fail + // Payload's e-mail validation and surface as a dead-end generic error). + expect(stubCampaignUserEmailFor('!!!')).toBe('assessor@criado.invalid') + }) +}) diff --git a/tests/unit/campaignMunicipalityAdvisorsCreate.unit.spec.ts b/tests/unit/campaignMunicipalityAdvisorsCreate.unit.spec.ts new file mode 100644 index 00000000..1a08e678 --- /dev/null +++ b/tests/unit/campaignMunicipalityAdvisorsCreate.unit.spec.ts @@ -0,0 +1,87 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createElement } from 'react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { EligibleAdvisorOption } from '@/utilities/municipality/municipalityViewModels' + +/** + * B154 — the "Criar assessor" affordance is the whole point of the item: it + * must appear exactly when the typed query matches no existing eligible advisor + * (and is long enough to create), and never otherwise. These cases pin that + * contract without touching the network — the popover is only opened and typed + * into; nothing is submitted. + */ +const MUNICIPALITY_NAME = 'Feira de Santana' + +const advisorControl = (options: EligibleAdvisorOption[]) => + createElement(MunicipalityListAdvisorsControl, { + municipalityID: 1, + municipalityName: MUNICIPALITY_NAME, + currentAdvisorIDs: [], + isPriority: false, + advisorNamesById: new Map(), + options, + variant: 'sheet', + }) + +const openAndType = async (query: string) => { + fireEvent.click(screen.getByRole('button', { name: /Editar assessores em/ })) + const dialog = await screen.findByRole('dialog') + const search = within(dialog).getByLabelText('Buscar assessor') + fireEvent.change(search, { target: { value: query } }) + return dialog +} + +beforeAll(() => { + // The advisors control renders `cmdk`, which measures its list and keeps the + // selected item in view. jsdom implements neither — same stub as the overlay + // spec. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + Element.prototype.scrollIntoView = () => {} +}) + +afterEach(cleanup) + +afterAll(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') +}) + +const renderControl = (element: ReturnType) => + render(createElement(TooltipProvider, null, element)) + +describe('MunicipalityListAdvisorsControl create option (B154)', () => { + it('offers "Criar assessor" when the query matches no eligible advisor', async () => { + renderControl(advisorControl([])) + + const dialog = await openAndType('Carlos') + const option = await within(dialog).findByRole('option', { name: /Criar assessor/ }) + expect(option.textContent).toContain('Carlos') + expect(within(dialog).queryByText('Nenhum resultado.')).toBeNull() + }) + + it('keeps the empty state for a query too short to create', async () => { + renderControl(advisorControl([])) + + const dialog = await openAndType('C') + await waitFor(() => expect(within(dialog).getByText('Nenhum resultado.')).toBeTruthy()) + expect(within(dialog).queryByRole('option', { name: /Criar assessor/ })).toBeNull() + }) + + it('never offers create when an existing advisor matches the query', async () => { + renderControl(advisorControl([{ id: 7, name: 'Carlos Souza', isCurrent: false }])) + + const dialog = await openAndType('Carlos') + expect(await within(dialog).findByRole('option', { name: /Carlos Souza/ })).toBeTruthy() + expect(within(dialog).queryByRole('option', { name: /Criar assessor/ })).toBeNull() + }) +}) diff --git a/tests/unit/campaignMunicipalityAdvisorsRoute.unit.spec.ts b/tests/unit/campaignMunicipalityAdvisorsRoute.unit.spec.ts new file mode 100644 index 00000000..c8de5266 --- /dev/null +++ b/tests/unit/campaignMunicipalityAdvisorsRoute.unit.spec.ts @@ -0,0 +1,88 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { POST } from '@/app/(campaign)/campanha/(app)/municipios/advisors/route' +import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types' +import { + createMunicipalityAdvisor, + setMunicipalityAdvisorMembership, +} from '@/app/(campaign)/campanha/actions/municipality' + +vi.mock('@/app/(campaign)/campanha/actions/municipality', () => ({ + createMunicipalityAdvisor: vi.fn(), + setMunicipalityAdvisorMembership: vi.fn(), +})) + +const mockedCreate = vi.mocked(createMunicipalityAdvisor) +const mockedToggle = vi.mocked(setMunicipalityAdvisorMembership) + +const postRequest = (body: unknown): Request => + new Request('http://localhost/campanha/municipios/advisors', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'http://localhost' }, + body: JSON.stringify(body), + }) + +const jsonBody = async (response: Response): Promise => + (await response.json()) as MunicipalityListAdvisorsResponse + +/** + * B154 — the endpoint contract is a discriminated union: the B27 toggle + * (`advisorId` + `assigned`, no `name`) and the name-only create (`name`, no + * `advisorId`) dispatch to different actions. These cases pin that a create + * body can never silently fall through to the toggle (or vice-versa). + */ +describe('POST /campanha/municipios/advisors body union (B154)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('dispatches the B27 toggle body to setMunicipalityAdvisorMembership', async () => { + mockedToggle.mockResolvedValue({ + advisors: [{ id: 7 }], + } as Awaited>) + + const response = await POST(postRequest({ municipalityId: 1, advisorId: 7, assigned: true })) + + expect(mockedCreate).not.toHaveBeenCalled() + expect(mockedToggle).toHaveBeenCalledWith({ municipality: 1, advisor: 7, assigned: true }) + expect(response.status).toBe(200) + await expect(jsonBody(response)).resolves.toMatchObject({ + status: 'success', + advisors: [7], + }) + }) + + it('dispatches the name-only create body to createMunicipalityAdvisor', async () => { + mockedCreate.mockResolvedValue({ + advisors: [{ id: 9 }], + createdAdvisorId: 9, + } as Awaited>) + + const response = await POST(postRequest({ municipalityId: 1, name: 'Carlos' })) + + expect(mockedToggle).not.toHaveBeenCalled() + expect(mockedCreate).toHaveBeenCalledWith({ municipality: 1, name: 'Carlos' }) + expect(response.status).toBe(200) + await expect(jsonBody(response)).resolves.toMatchObject({ + status: 'success', + advisors: [9], + createdAdvisor: { id: 9, name: 'Carlos' }, + }) + }) + + it.each([ + ['no dispatch shape at all', { municipalityId: 1 }], + ['both shapes at once', { municipalityId: 1, advisorId: 7, assigned: true, name: 'Carlos' }], + ['create with a toggle remnant', { municipalityId: 1, name: 'Carlos', assigned: false }], + ['name too short', { municipalityId: 1, name: 'C' }], + ['blank name', { municipalityId: 1, name: ' ' }], + ])('rejects %s with 400 without calling either action', async (_label, body) => { + const response = await POST(postRequest(body)) + + expect(mockedToggle).not.toHaveBeenCalled() + expect(mockedCreate).not.toHaveBeenCalled() + expect(response.status).toBe(400) + }) +}) From 5a52bb0efa566d6d6ebe0801048a76c8091be817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Mon, 3 Aug 2026 23:13:01 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(int):=20batch=20B19=20usa=20munic=C3=AD?= =?UTF-8?q?pios=20alocados=20=E2=80=94=20remove=20ra=C3=A7a=20com=20cap=20?= =?UTF-8?q?de=20assessores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O teste 'assigns and removes multiple municipalities in one batch' escolhia o segundo município com id not_equals cru; sob ~48 workers paralelos podia cair num município que um spec paralelo (cap 10 do B27/B154) acabara de encher, fazendo o batch estourar o teto. getMunicipality() aloca município globalmente único e purga resíduo. --- ...ar-assessor-inline-popover-municipios-impl.md | 5 +++++ tests/int/campaignAdvisorManagement.int.spec.ts | 16 ++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/plans/criar-assessor-inline-popover-municipios-impl.md b/docs/plans/criar-assessor-inline-popover-municipios-impl.md index 142cd3cf..cbb22d8b 100644 --- a/docs/plans/criar-assessor-inline-popover-municipios-impl.md +++ b/docs/plans/criar-assessor-inline-popover-municipios-impl.md @@ -140,3 +140,8 @@ Três revisores paralelos (qualidade, reuso/DRY, comportamento) no diff do B154; - Reconcile assume commit na ordem de envio (machinery-wide do B27, toggle compartilha): um interleave raro pode dropar o chip do create até a próxima navegação. **Gatilho:** chip sumindo observado em produção. **Descartado:** renomear `isPlanilhaPlaceholderEmail` (drive-by de 4 call sites pré-existentes); alias table do E4R no stub (TLDs distintos, sem colisão); `payload-types.ts` com drift do B134 (regenerado por cada run e2e — pertence à entrega do B134). + +**Pré-existente documentado (não é débito do B154):** + +- **Raça do batch B19** (`campaignAdvisorManagement.int.spec.ts`): o teste escolhia o 2º município com `id not_equals` cru — podia cair num município que um spec paralelo acabara de encher até o cap 10. Os testes de cap do B154 agravaram a probabilidade. **Fixado neste PR**: `getMunicipality()` nos dois municípios (alocação globalmente única + purga de resíduo). +- **Flake pré-existente `testDatabaseLease` × `onda0Provision`** (`~1 em 4–5 runs`, ~48 workers paralelos): `onda0Provision.int.spec.ts` grava os textos Onda 0 (banner "Texto provisório…") na mesma row `lideranca-autopreenchimento` que `withMutableConsentFixture` muta/restaura — os dois specs de infra disputam a row sem coordenação. Reproduz em main limpo; nenhuma relação com B154 (nenhum arquivo deste PR toca consent/lease). Fix exige coordenação entre os dois specs de infra (ex.: onda0Provision respeitar o lease) — fora do escopo desta entrega, registrado aqui para não se perder. diff --git a/tests/int/campaignAdvisorManagement.int.spec.ts b/tests/int/campaignAdvisorManagement.int.spec.ts index 865e981e..867a033a 100644 --- a/tests/int/campaignAdvisorManagement.int.spec.ts +++ b/tests/int/campaignAdvisorManagement.int.spec.ts @@ -199,17 +199,13 @@ describe('campaign advisor management (B19)', () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const advisor = await fixtures.createCampaignUser('advisor') + // Both municipios go through the allocation mechanism: globally unique + // across parallel spec files AND purged of residue on claim. The old + // arbitrary `id not_equals` pick could land on a municipio a parallel test + // had just filled to the 10-advisor cap, making the batch throw. const first = await fixtures.getMunicipality() - const second = ( - await payload.find({ - collection: 'municipality', - where: { id: { not_equals: first.id } }, - limit: 1, - depth: 0, - overrideAccess: true, - }) - ).docs[0] - expect(second).toBeTruthy() + const second = await fixtures.getMunicipality() + expect(second.id).not.toBe(first.id) await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, { advisorId: advisor.id,