Achado do /simplify pós-entrega (findByID por município ao atribuir um
território de identidade ou zona eleitoral inteira). Sem precedente para
o fix natural (payload.find com id: { in }) e mudaria semântica de erro
para IDs inexistentes; fica como gatilho no plano em vez de item de
roadmap.
15 KiB
B19 — Gerenciar assessores (/campanha/assessores)
Status: entregue em código (2026-07-24)
Atualizado em: 2026-07-24
Item do roadmap: docs/roadmap.md (Demais itens abertos, B19; superfície de coordenação)
Impeccable: C — UI nova em /campanha/assessores (lista + novo + detalhe); sem design-ref
Appetite: ~1,5–2 dias eng; lista + create + detalhe/edição in-context + gate de nav; sem migration; alinhamento de access candidate↔coordinator nos helpers de campaignUser e na atribuição municipality.advisors
Responsável: —
Revisões
- 2026-07-25 (edit-where-you-see na lista): a tabela em
/campanha/assessorespassou a ser a superfície única — e-mail/celular/nome com auto-save com debounce; chips de municípios com remoção no X; busca inline por município / território de identidade (chip agregado) / ZE (chips por município); WhatsApp por linha; “+ Novo assessor” cria linha no topo (sem/novonem detalhe[id], que só redirecionam). BatchsetAdvisorMunicipalitiesBatchpara TI/ZE. A carteira colapsa em 3 linhas com “Ver mais…” como último item da terceira linha: a célula mede o layout real (linhas de chips, largura do toggle e do input de busca) e corta a lista de chips, em vez de cortar por altura — a medição é reavaliada ao alternar edição, porque o chip removível é mais largo. - 2026-07-24 (implementação): entregue. Auditoria pré-build corrigiu quatro defasagens: (1) atribuição de municípios alinhada a
isCampaignUnrestrictednas três camadas (canAssignMunicipalityAdvisors,assignMunicipalityAdvisorsRecord, mensagem segura) — decisão de produto: Candidato designa igual ao CG; (2) coluna E-mail via leitura privilegiada (overrideAccess: truesemuserapós o gate de rota — e-mail completo, não só badge); (3) carteira do assessor com auto-save por município (delta + lock), não o Popover "Salvar" do B9; (4)sendAdvisorPasswordResetpróprio (resultado honesto; bloqueia@planilha.invalid).reloadUnrestrictedActoradicionado. Rotas/campanha/assessores,/novo,/[id]; nav gated; testes intcampaignAdvisorManagement.int.spec.ts.
Design (Impeccable)
Âncoras: PRODUCT.md (princípios 2 clareza sob pressão, 3 Edit where you see, 4 Auto-save, 8 Feel the action) / DESIGN.md (register product; Field Desk) · tema data-theme='campaign' · precedente de lista+CRUD organizacoes/page.tsx e dobradinhas/.
Na implementação (implement-roadmap-item): shape → craft → critique → polish.
Brief compacto:
- Persona / contexto: Alex (Coordenador Geral) e o Candidato no desk — onboarding do time (Onda 0 §4) e manutenção da carteira de assessores; sem ir ao Payload
/admin(JWT de campanha não entra lá). - Job principal: criar e manter contas
advisor(nome, e-mail, celular) e ver/ajustar quais municípios cada um administra. - Estratégia de cor: Restrained — lista densa Field Desk; sem hero-metric de “N assessores”.
- Edit where you see: sim — campos de conta e vínculo a municípios editáveis no detalhe (Popover/multi-select + auto-save); create multi-campo com submit explícito.
- Anti-goals: página “Equipe” genérica com todos os papéis; IAM tipo SaaS; ranking/gamificação de assessores; spreadsheet de municípios×assessores; expor a lista a
advisor/leader.
Dados → decisão → apresentação
- Vou apresentar dados? Sim, superfície neste item — lista operacional de pessoas + contagem de municípios, não KPI eleitoral.
- Decisões desbloqueadas:
- Coordenador / Candidato: “este assessor já tem login válido (e-mail real) ou ainda é placeholder da planilha?”
- Coordenador / Candidato: “quem cobre quantos / quais municípios — falta alguém na carteira X?”
- Coordenador / Candidato: “criar assessor novo agora e depois atribuir municípios?”
- Forma escolhida: tabela/lista (nome, e-mail, celular, Nº municípios, link detalhe) + detalhe com lista dos municípios — por quê: comparar muitas entidades e agir; degrau mais pobre que resolve. Rejeitado: dashboard de carga por assessor (vaidade / gaming — research G4); mapa de carteiras (outro job; B13/E12); chart de distribuição.
- Profile: categórico + contagem; granularidade =
campaignUser(roleadvisor); tamanho típico dezenas (seed E4R ~24 assessores); absoluto só como contagem de vínculos. - Anti-goals de dado: sem % estadual; sem ranking de performance; sem KPI de “cadastros feitos pelo assessor”.
Self-check dados: 5/5.
Contexto
Onde se gerencia assessor hoje:
- Conta (
campaignUsercomrole: 'advisor'): só via Payload admin (/admin) ou seedpnpm db:seed:projecao(e-mails@planilha.invalid, sem login até trocar). CG/candidato de campanha não acessam/admin. - Carteira:
municipality.advisors— já editável na lista/detalhe viaMunicipalityListAdvisorsControl(B9) +eligibleCampaignStaffWhere. - Access já existe:
canManageCampaignUsers=isCampaignUnrestricted(create/delete);canManageCampaignUserRole= unrestricted; mascanUpdateCampaignUser/ phone create-update ainda privilegiam sócoordinator(assimetría pós-rolecandidate— bug de produto para esta superfície). - Hook
preventAssignedAdvisorDowngradeimpedeadvisor→leaderenquanto houver municípios; self-service no perfil não alterarole/name/email/phone.
Pedido de produto (2026-07-24): página na vertical /campanha para gerenciar assessores, visível só a Coordenador Geral e Candidato.
Objetivos
- Rotas
/campanha/assessores,/campanha/assessores/novo,/campanha/assessores/[id](id numérico, como lideranças). - Gate de página + item de nav: só
isCampaignUnrestricted;advisor/leader→ redirect/campanha(e item ausente no sidebar). - Lista: busca por nome/e-mail; colunas Nome · E-mail · Celular · Municípios (contagem) · link para detalhe; só
role === 'advisor'. - Criar assessor: nome + e-mail (obrigatório para login staff) + celular opcional; senha aleatória server-side; se e-mail entregável, disparar
forgotPassword(fluxo já emactions/password.ts) — CG não vê a senha. - Detalhe: editar nome/e-mail/celular (auto-save); listar municípios administrados com link; atribuir/remover municípios no contexto (atualiza
municipality.advisors); CTA “Enviar link de redefinição de senha”. - Sem delete na UI v1 (collection delete permanece para admin Payload).
- Sem migration, sem collection nova, sem
Consentnovo. - Testes int: gate de rota/access (advisor negado; unrestricted ok) + create com
overrideAccess: false.
Decisões travadas
- Visibilidade e escrita =
isCampaignUnrestricted(coordinator + candidate). Pedido explícito; alinha create já existente. Rejeitado: só coordinator (candidate ficaria cego no onboarding); staff inteiro vê a lista (assessor não gerencia pares — vazaria e-mails/carteiras); Payload/admincomo UX principal (CG não entra). - Alinhar access de update/phone e atribuição de municípios a unrestricted.
canUpdateCampaignUser/ phone create-update /canAssignMunicipalityAdvisors/assignMunicipalityAdvisorsRecordpassam aisCampaignUnrestricted(+ admin). Rejeitado: deixar assimetria (página inútil para candidate na carteira); página “só leitura” para candidate. - E-mail visível na lista/detalhe para unrestricted. Loader usa
overrideAccess: truesemuserapós o gate (hookremovePrivateAuthFields+ field access self-only apagariam o e-mail). Rejeitado: só badge de status; ocultar e-mail (impede ativar placeholders E4R). - Escopo = só papel
advisor. Create forçarole: 'advisor'; lista filtra esse role. Coordinator/candidate continuam fora desta UI. Rejeitado: “Equipe” multi-papel (explode IAM); promover assessor a coordinator nesta tela. - URL
/campanha/assessores+ detalhe por[id]. Português no segmento (padrão da vertical); id interno (sem slug emcampaignUser). Rejeitado:/campanha/usuariosgenérico; slug inventado. - Sem delete na UI v1. Evita órfãos e disputa com vínculos. Desativar = remover municípios + (futuro) flag — Adiado. Rejeitado: soft-delete/collection
archivedneste appetite; hard-delete com cascade silenciosa. - Senha: nunca exibida; onboarding via forgot-password / e-mail. Action própria
sendAdvisorPasswordReset(não o forgot público anti-enumeração). Placeholder@planilha.invalidexige troca de e-mail antes do envio. Rejeitado: senha temporária na tela (vazamento em share/screenshot); SMS. - Atribuição de municípios no detalhe do assessor (além de B9) com auto-save por delta. Um município por toggle + advisory lock; B9 continua no sentido município→assessores. Rejeitado: só links “vá à lista de municípios”; segunda planilha full-grid; botão Salvar no Popover (anti-goal Auto-save).
- i18n e naming: identificadores em inglês (
AdvisorList,loadAdvisorListPageData,createAdvisor,setAdvisorMunicipalityMembership,reloadUnrestrictedActor); strings pt-BR (“Assessores”, “Novo assessor”, “Enviar link de senha”).
Questões em aberto
- Nav: item no sidebar staff filtrado, ou só link a partir do Início/onboarding? Resolvido (craft): A — item “Assessores” no
staffNavsó se unrestricted; bottom nav mobile não inclui. - Create: celular obrigatório? Resolvido: opcional.
- Atribuição em lote no create (municípios no mesmo form)? Resolvido: create só conta; municípios no detalhe com auto-save.
Abordagem proposta
flowchart LR
Gate["isCampaignUnrestricted<br/>page + nav"]
List["/campanha/assessores<br/>lista + busca"]
New["/campanha/assessores/novo"]
Detail["/campanha/assessores/id"]
Actions["actions/advisor.ts<br/>create / update / municipalities / reset"]
CU["campaignUser<br/>role=advisor"]
Mun["municipality.advisors"]
Mail["forgotPassword / Resend"]
Gate --> List --> Detail
Gate --> New --> Detail
List --> Actions
New --> Actions
Detail --> Actions
Actions --> CU
Actions --> Mun
Actions --> Mail
Componentes:
reloadUnrestrictedActoremsrc/utilities/campaignActionContext.ts(espelhareloadCoordinatorActorcomisCampaignUnrestricted) — depth: reusar o padrão; não inventar policy paralela.- Access: ajustar
canUpdateCampaignUser(não-self),canCreateCampaignUserPhone,canUpdateCampaignUserPhoneparaisCampaignUnrestricted(+ admin) emsrc/utilities/access/campaignUsers.ts; int tests cobrindo candidate. src/utilities/advisorData.ts(server-only):parseAdvisorListParams,loadAdvisorListPageData(findcampaignUserwhere role=advisor + contagem de municípios via query agregada ou map a partir demunicipality.advisors),loadAdvisorDetailcom lista de municípios;selectmínimo;overrideAccess: false.src/app/(campaign)/campanha/actions/advisor.ts:createAdvisor/updateAdvisorProfile/setAdvisorMunicipalities/sendAdvisorPasswordReset— assert unrestricted; create com senha aleatória (crypto.randomBytes) +role: 'advisor'; municipalities em transação se N writes; reset só se e-mail não-placeholder.- UI:
AdvisorList/AdvisorForm/AdvisorDetail/AdvisorMunicipalitiesControlemsrc/components/campaign/— shellsCampaignPageShell,CampaignListPendingBoundary,CampaignSearchForm,CampaignListPagination; Feel the action nos controles. nav.ts: item “Assessores” (íconeUserCogou similar) incluído só quandoisCampaignUnrestricted(role); bottom nav inalterado.- Rotas sob
src/app/(campaign)/campanha/(app)/assessores/. - Sem migration.
Dependências
- Nenhuma dura de outro item aberto. Reusa
campaignUseraccess/hooks, B9 (municipality.advisors), reset de senha (actions/password.ts/ Resend), shells de lista (organizações/dobradinhas),eligibleCampaignStaffWhere. - Suave: onboarding Onda 0 §4 (esta UI é o caminho sem
/admin); E4R seed já populou assessores placeholder — a página é o jeito de “ativar” e-mails reais.
Não escopo
- Gerenciar
coordinator/candidate/leadernesta UI. - Delete / arquivar assessor.
- Inbox cruzado ou métricas de performance por assessor (D5 / research anti-gaming).
- Convite WhatsApp tipado para assessor (D3–D4; staff login = e-mail).
- Preferências UI syncadas em
campaignUser(B17/B18). - Alterar o modelo
municipality.advisors(hasMany permanece).
Rabbit holes
- IAM completo (desativar, auditoria, 2FA, sessões). Explode appetite. Mitigação: create/edit/reset + lista; sem delete.
- Planilha municípios×assessores bidirecional full-grid. Conflita com anti spreadsheet do PRODUCT. Mitigação: multi-select no detalhe + B9 no sentido inverso; sem data-grid.
- Unificar com Organizações / “cadastros de referência” genérico. Pass-through raso. Mitigação: actions e loaders nomeados
advisor*;runStaffEntityMutationsó se couber sem forçar policy staff genérica (aqui é unrestricted). - Consent por e-mail de assessor. Staff interno, não opt-in de apoiador. Mitigação: sem chave nova; se jurídico pedir depois, defer.
Adiado com gatilho
- Delete / desativar assessor na UI. Revisitar quando: CG pedir remoção recorrente e houver regra clara para municípios órfãos (reassign obrigatório).
- Create com municípios no mesmo submit. Revisitar quando: onboarding medir ≥2 cliques extras como atrito real pós-R6.
- Nav mobile dedicado. Revisitar se bottom nav for redesenhado (hoje teto 5).
- N+1 no batch de atribuição de território/ZE (
setAdvisorMunicipalitiesBatchRecord). UmfindByIDpor município ao adicionar/remover um território de identidade ou zona eleitoral inteira via chip agregado (achado do/simplifypós-entrega). O fix natural (payload.findcomid: { in: [...] }) não tem precedente no código e mudaria o comportamento de erro para IDs inexistentes — não vale trocar sem necessidade real. Revisitar quando: a base de assessores/territórios crescer o suficiente para o lote ficar perceptivelmente lento, ou o usuário reportar demora ao atribuir um território completo.
Referências
docs/roadmap.md(Demais itens abertos · B19; Onda 0 §4 onboarding)src/collections/CampaignUser.ts— auth, hooks de downgrade/self-service, fieldssrc/utilities/access/campaignUsers.ts—canManageCampaignUsers, update/phone asymmetrysrc/utilities/access/shared.ts—isCampaignUnrestricted,eligibleCampaignStaffWheresrc/components/campaign/MunicipalityListAdvisorsControl.tsx— precedente de atribuiçãosrc/components/campaign/nav.ts— gate de itens por papelsrc/app/(campaign)/campanha/(app)/organizacoes/page.tsx— shell de lista CRUDsrc/app/(campaign)/campanha/actions/password.ts— forgotPassword / Resendsrc/utilities/campaignActionContext.ts—reloadCoordinatorActor(padrão a espelhar)- AGENTS.md — Campaign auth, naming,
overrideAccess: false, candidate = unrestricted visibility PRODUCT.md/DESIGN.md— Field Desk; Edit where you see; Feel the action