Merge pull request #357 from fsolla/agent/352-criar-assessor-inline-popover-municipios

B154 — criar assessor inline no popover da lista de municípios
This commit is contained in:
Francisco Solla 2026-08-03 23:33:40 -03:00 committed by GitHub
commit 464b69884c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1018 additions and 67 deletions

View file

@ -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 '<nome>'" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `<slug>@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`.

View file

@ -0,0 +1,147 @@
# 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 `<slug-do-nome>@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'<br/>sem match"] -->|"filteredOptions == 0<br/>e query ≥ 2"| createItem["+ Criar assessor 'Carlos'"]
createItem -->|"Enter/clique"| optimistic["temp id negativo no selectedIDs<br/>+ pendingCreates + spinner"]
optimistic --> post["POST /campanha/municipios/advisors<br/>body { municipalityId, name }"]
post --> route["body union: 'name' in body?"]
route -->|"create"| record["createMunicipalityAdvisorRecord<br/>(mesma transação + lock)"]
record -->|"1) probe stub livre"| u["payload.create campaignUser<br/>(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<br/>+ 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 '<query>'" (`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).
**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.

View file

@ -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<MunicipalityListAdvisorsResponse>({
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<MunicipalityListAdvisorsResponse>({
status: 'success',
message: assigned ? 'Assessor atribuído.' : 'Assessor removido.',
message: body.assigned ? 'Assessor atribuído.' : 'Assessor removido.',
advisors: uniqueRelationshipIds(updated.advisors),
})
},

View file

@ -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'

View file

@ -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
* `<slug>@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 `<slug>@criado.invalid` stub: bounded exact-match probes on the
* unique `email` index (no LIKE semantics). Collisions only happen when two
* names slugify identically, so the first probe usually succeeds. The read
* bypasses access control on purpose: the transaction already verified the
* unrestricted role, and the probe only looks at placeholder e-mails.
*/
const nextFreeStubCampaignEmail = async (
payload: Payload,
req: PayloadTransactionRequest,
name: string,
): Promise<string> => {
for (let occurrence = 1; occurrence <= 50; occurrence += 1) {
const candidate = stubCampaignUserEmailFor(name, occurrence)
const existing = await payload.find({
collection: 'campaignUser',
where: { email: { equals: candidate } },
depth: 0,
limit: 1,
select: { email: true },
// Intentional admin bypass: unrestricted role verified above in the
// transaction; the probe only reads placeholder e-mails.
overrideAccess: true,
req,
})
if (existing.totalDocs === 0) return candidate
}
throw new Error(MUNICIPALITY_ADVISOR_STUB_EMAIL_MESSAGE)
}

View file

@ -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<MunicipalityAdvisorCreateContextValue | null>(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<EligibleAdvisorOption[]>([])
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 (
<MunicipalityAdvisorCreateContext.Provider value={contextValue}>
{children}
</MunicipalityAdvisorCreateContext.Provider>
)
}

View file

@ -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}
<SaveMunicipalityFilterControl state={state} />
</>
}
trailing={[
// React reconciles unkeyed fragments' children as a keyed list — a
// bare `<> {trailing} <Save…/> </>` 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.
<Fragment key="column-picker">{trailing}</Fragment>,
<SaveMunicipalityFilterControl key="save-filter" state={state} />,
]}
/>
</form>
)

View file

@ -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 (
<>
<MunicipalityAdvisorCreateProvider>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
@ -479,6 +480,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}
/>
</>
</MunicipalityAdvisorCreateProvider>
)
}

View file

@ -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<number, string> = 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<ReadonlyMap<number, string>>(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<number, EligibleAdvisorOption>()
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<number, { id: number; name: string }>(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<MunicipalityListAdvisorsResponse>(
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<MunicipalityListAdvisorsResponse>(
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 (
<CampaignCellEditOverlay
@ -224,25 +341,38 @@ export const MunicipalityListAdvisorsControl = ({
</div>
{chips.length > 0 ? (
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
{chips.map((chip) => (
<button
key={chip.id}
type="button"
// A 20px pill is a fine mouse target and a bad thumb one: the
// Drawer pads it to the 44px minimum without growing the pill.
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
aria-label={`Remover ${chip.name}`}
onClick={() => toggle(chip.id, false)}
>
{chips.map((chip) =>
// A pending-create chip has no remove affordance: the server-side
// create cannot be cancelled mid-flight, so removal waits for the
// real account (and the real toggle) once the response lands.
isPendingCreateId(chip.id) ? (
<Badge
key={chip.id}
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
>
<span className="truncate">{chip.name}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
))}
) : (
<button
key={chip.id}
type="button"
// A 20px pill is a fine mouse target and a bad thumb one: the
// Drawer pads it to the 44px minimum without growing the pill.
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
aria-label={`Remover ${chip.name}`}
onClick={() => toggle(chip.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{chip.name}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
),
)}
</div>
) : null}
<Command shouldFilter={false} className="mt-1">
@ -254,7 +384,20 @@ export const MunicipalityListAdvisorsControl = ({
/>
<CommandList>
{filteredOptions.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nenhum resultado.</p>
trimmedQuery.length >= 2 && trimmedQuery.length <= 160 ? (
<CommandItem
value={`create-${trimmedQuery}`}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => createAdvisor(trimmedQuery)}
>
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate">Criar assessor “{trimmedQuery}”</span>
</CommandItem>
) : (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado.
</p>
)
) : (
filteredOptions.map((option) => {
const assigned = selectedSet.has(option.id)

View file

@ -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): `<slug-do-nome>@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,
})

View file

@ -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 `<slug>@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<typeof municipalityAdvisorMembershipSchema>
export type MunicipalityAdvisorCreateInput = z.input<typeof municipalityAdvisorCreateSchema>
export type MunicipalityExpectedVotesInput = z.input<typeof municipalityExpectedVotesSchema>
export type MunicipalityEngagementLevelInput = z.input<typeof municipalityEngagementLevelSchema>

View file

@ -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) =>

View file

@ -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()

View file

@ -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,

View file

@ -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)
})
})

View file

@ -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 <slug-do-nome>@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')
})
})

View file

@ -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<typeof advisorControl>) =>
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()
})
})

View file

@ -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<MunicipalityListAdvisorsResponse> =>
(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<ReturnType<typeof setMunicipalityAdvisorMembership>>)
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<ReturnType<typeof createMunicipalityAdvisor>>)
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)
})
})