mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
commit
464b69884c
18 changed files with 1018 additions and 67 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
147
docs/plans/criar-assessor-inline-popover-municipios-impl.md
Normal file
147
docs/plans/criar-assessor-inline-popover-municipios-impl.md
Normal 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.
|
||||
|
|
@ -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),
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
87
tests/unit/campaignMunicipalityAdvisorsCreate.unit.spec.ts
Normal file
87
tests/unit/campaignMunicipalityAdvisorsCreate.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
88
tests/unit/campaignMunicipalityAdvisorsRoute.unit.spec.ts
Normal file
88
tests/unit/campaignMunicipalityAdvisorsRoute.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue