15 KiB
Impl: Identidade — toda pessoa da campanha tem uma ficha Contact
Status: aprovado Atualizado em: 2026-08-09 Issue: #494 Intenção: docs/plans/pessoas-identidade-contact.md Appetite restante: ~1–1,5 dia eng (herdado); uma migration aditiva
Leitura da intenção
- Outcome:
campaignUserganha vínculo opcional comContact(uma conta → uma ficha), garantido no fluxo de criação/edição de staff (cria ou vincula — nunca pede dados da pessoa duas vezes);leadershipganhaadvisors(assessores responsáveis, mesmo padrão destateDeputy.advisors). C100 (lista unificada) passa a conseguir juntar staff, lideranças e dobradinhas pela mesma ficha. - O que NÃO negociar: auth intacta (login e-mail/celular, token, sessão, WebAuthn, convites, notificações); leader lockdown; consentimentos intactos;
leadership.userpermanece; sem backfill por heurística (vínculo só por decisão explícita de criação/edição); estado de produto "sem superfície nova". - O que reavaliar: a hipótese "mudar cada fluxo de criação de usuário" — um hook de collection cobre admin + criação inline de assessor (action + popover do município) + convite, com um dono só; e o vínculo NÃO precisa ser único no sentido inverso (duas contas da mesma pessoa física podem apontar para a mesma ficha — é exatamente o dedupe que a C100 quer).
Abordagem recomendada
flowchart LR
A[beforeChange em campaignUser] --> B{data.contact set?}
B -- sim --> C[respeita o explícito; sync de identidade se mudou]
B -- não --> D{tem telefone?}
D -- sim --> E[lock contact-phone → find por telefone]
E -- 1 ficha --> F[vincula: data.contact = ficha]
E -- 0 fichas --> G[cria ficha BA com nome/telefone/e-mail não-stub]
E -- 2+ fichas --> H[erro AMBIGUOUS fail-closed]
D -- não --> G
C --> I[leadership.advisors: campo + validação + action + célula]
F --> I
G --> I
Opções consideradas: A) hook beforeChange em campaignUser (cria/vincula por telefone + sync de identidade) | B) chamadas explícitas em cada server action + campo manual no admin | C) hook só no create, sem sync em update
Recomendação: A — um dono só para a invariante "conta tem ficha"; cobre o admin (Payload não permite hook de UI) e os três fluxos de criação (action de assessor, popover inline do município, resgate de convite) sem N call sites espalhados; o sync de identidade em update mantém a ficha fresca quando a coordenação edita o staff em /campanha/assessores.
Rejeitadas: B porque deixa o admin sem o comportamento "sem pedir duas vezes" (a coordenação criaria a ficha à parte) e duplica a invariante em N ações; C porque o aceite fala em "criação/edição" e a C100 leria telefone/nome obsoletos da ficha após edições rotineiras do staff.
Componentes / mudanças
src/collections/CampaignUser.ts:- Campo novo
contact(relationship →contact, optional,index: true, label "Ficha de contato"). - Field access: read =
canReadCampaignUserIdentity(própria conta ou admin — mesmo tratamento de email/username); create/update =canSetCampaignSystemField(só admin escreve explícito; o hook preenche no fluxo staff). preventSelfServicePrivilegedFields: adicionarcontactà lista de campos removidos em self-service (usuário não realinha a própria identidade).- Hook novo
beforeChangeensureCampaignUserContactIdentity(último da ordem, apóspreventSelfServicePrivilegedFields):- Create:
data.contactexplícito → respeita; senão → cria/vincula por telefone (lockcontact-phone:<phone>adq. na própria transação; >1 ficha →CONTACT_PHONE_AMBIGUOUS_MESSAGE; 1 → vincula; 0 → cria ficha{ name, email: só e-mail não-placeholder, phone, state: 'BA', city: null }comoverrideAccessjustificado — precedentecreateStateDeputyWithContact). - Update:
contactpresente no data → respeita (inclusive null explícito); ausente eoriginalDoc.contactset → sync 1 via (conta → ficha) de name/email (não-placeholder)/phone quando a identidade mudou (a ficha tem o próprio hookenforceUniqueContactPhone→ conflito = falha da operação com mensagem padrão, fail-closed, mesma semântica do fluxo de contato de dobradinha); ausente e sem vínculo → mesmo cria/vincula do create.
- Create:
- Sem
unique: trueno campo (ver Decisões).
- Campo novo
src/collections/Leadership.ts: campoadvisors(relationship →campaignUser, hasMany,index: true, label "Assessores responsáveis",filterOptions: eligibleCampaignStaffWhere, access create =canAssignCampaignStaffAdvisors, update =canManageCampaignStaffAdvisors) + validaçãobeforeValidatevia hook compartilhado extraído.src/utilities/contactIdentity.ts(novo, extraído):findOrCreateContactByPhone— lock + find (limit 2) + ambíguo/reusa/cria comstate: 'BA'(aceitaphone: null→ ficha name-only;gender/cityopcionais).supporter.tsmigraupsertContactByPhonepara ele (lock passou a ser interno ao utility — reentrante na mesma transação; oskipContactPhoneInvariantdo create é mantido, adquirido junto com o lock na mesma transação, fail-closed do hook da ficha).campaignUserhook e o create de liderança usam o mesmo utility (3 call sites, política "cidade padrão BA" com dono único).src/utilities/campaignStaffAdvisors.ts(novo): hook de validaçãovalidateEligibleCampaignStaffAdvisors(3ª cópia idêntica → extrai deMunicipality.tseStateDeputy.ts) + acessocanAssignCampaignStaffAdvisors/canManageCampaignStaffAdvisors(rename+move deaccess/stateDeputies.ts, que tinha nomes específicos de dobradinha). Municípios mantémcanAssignMunicipalityAdvisorspróprio (semântica de carteira, pode divergir — comentário E14 existente).src/lib/leadershipAdvisorMembership.ts(novo):nextLeadershipAdvisorIdsAfterMembership— mesmo bounded-delta destateDeputyAdvisorMembership.tscom capMAX_ADVISORS_PER_LEADERSHIP = 10.src/app/(campaign)/campanha/actions/leadership.ts:setLeadershipAdvisorMembership— espelho desetStateDeputyAdvisorMembershipRecord(lockleadership-advisors:<id>,reloadUnrestrictedActor, update comoverrideAccess).src/components/campaign/leadership/LeadershipAdvisorRelationCell.tsx(novo): 2º thin wrapper sobreRelationOptionCell(padrão B156; chips linkam/campanha/assessores/[id],readOnlypara staff sem atribuição).src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx+formActions.ts: seção "Assessores responsáveis" na ficha existente (mesmo layout da dobradinha:canEditAdvisors = isCampaignUnrestricted(user),loadEligibleAdvisorOptionsquando editável) +setLeadershipAdvisorMembershipFormAction(revalidate/campanha/liderancas/[id]).src/utilities/leadership/leadershipData.ts:LeadershipDetailViewModelganhaadvisors: Array<{ id, name }>resolvidos vialoadCampaignUserNamesByIds(bypass justificado já documentado no helper).src/utilities/campaignInviteRedemption.ts: create do account de líder ganhacontact: leadership.contact(a ficha já existe — vínculo do dedupe liderança/staff).- Migration:
migrate:create—campaign_user.contact_id(FK → contacts,ON DELETE set null) + joinleadership_rels.campaign_user_id(FK → campaign_users, cascade — a tabela join padrão do Payload para o hasMany). Sem backfill (intenção corta varredura por heurística). - Access / Consent: nenhuma chave nova; fail-closed herdado (
AMBIGUOUS, conflito de telefone). Locks de telefone nos padrões existentes. - UI: Impeccable A (sem superfície nova) — a seção de assessores usa o shell/layout da ficha de liderança existente e o padrão B156 de células.
Dados → forma (se aplicável)
N/A — C99 não apresenta dados; habilita o dedupe da C100 (um vínculo, não um número). A coluna "Assessorado" da C100 lê leadership.advisors / stateDeputy.advisors.
Fases verificáveis
- Tracer / schema+server — migration aditiva (campo em
campaignUser+leadership.advisors), utilitycontactIdentity, hookensureCampaignUserContactIdentity, hook/access de assessores compartilhados, actionsetLeadershipAdvisorMembership,contactno resgate de convite,contactno strip de self-service. Gates:generate:types,tsc, unit/int novos. - UI — célula
LeadershipAdvisorRelationCell+ seção na ficha de liderança + view-model com advisors + form action. Gates:lint, int de página/access. - Gates —
pnpm gate:fastna iteração; entrega compnpm push. Testes novos:contactIdentity(int: cria/vincula por telefone, ambíguo, sync de identidade, e-mail stub não vai para a ficha, contato explícito respeitado, duas contas → mesma ficha sem conflito),campaignLeadershipAdvisorMembership.int.spec.ts(espelho do spec de dobradinha), extensão decampaignAdvisorManagement.int.spec.ts(vínculo no create) e do spec de access (criação/update do campo).
Rabbit holes / Não escopo (engenharia)
- Sync de username (celular de login) para a ficha — username é credencial, não identidade.
- Dedupe por e-mail —
Contact.emailnão é único e o precedente de dedupe é telefone. - Vincular staff antigo em varredura (backfill) — intenção corta; só edição/criação por documento.
- Relink/merge de fichas duplicadas — pós-eleição (intenção).
- Tocar
stateDeputy/supporter— já vinculados;supportersó ganha o import do utility extraído.
Riscos e mitigação
- Hook roda em toda escrita de
campaignUser(ex. troca de senha, avatar): sync só dispara quando identidade muda e há vínculo; avatar/senha/advisors não tocam a ficha. Login não passa por hooks (sessão via adapter, precedente WebAuthn). - Contato da ficha é
stateobrigatório: cria com'BA'+city: null(precedente dobradinha); a ficha de staff nasce com telefone opcional, como hoje. - Dois
campaignUsercom o mesmo telefone (permitido hoje): passam a compartilhar a mesma ficha — sem erro, e é o dedupe desejado da C100 (uma pessoa, duas contas). - Fixtures de teste criam
campaignUser→ hook criaContact: a purge de fixtures já varrecontactpor runID (nome com marcador) — verificado emcampaignFixtures.ts; rodar suíte int completa para confirmar nenhum count de contact quebrou. - E-mail placeholder (
@planilha.invalid/@criado.invalid) na ficha: filtrado pelo predicateisPlanilhaPlaceholderEmail(só e-mails reais vão para a ficha). removePrivateAuthFields(afterRead): não removecontact— vínculo não é credencial; exposição do ID da ficha é inócua (a ficha em si é access-gated), e o field access read own/admin limita a superfície.- Recursão de hook: o vínculo é injetado no
dataembeforeChange(sem segunda escrita); o sync de update escreve na ficha, que tem hooks próprios mas não chama hooks decampaignUser— sem loop. - Locks de telefone exigem transação ativa:
beforeChangede operação roda dentro da transação da operação (precedenteenforceUniqueContactPhoneem produção);skipContactPhoneInvariantdo utility herda o fail-closed existente.
Decisões de engenharia (formato obrigatório)
- Mecanismo do vínculo: Opções: hook
beforeChangeúnico | N ações + admin manual | hook só create. Recomendação: hook único — um dono da invariante, cobre admin e os 3 fluxos. Rejeitadas: N ações (invariante espalhada, admin fica de fora); só-create (ficha fica obsoleta na edição, contradiz "criação/edição"). - Unicidade do vínculo: Opções:
unique: trueno campo | sem unique (inverso pode ter N contas por ficha). Recomendação: sem unique — a mesma pessoa física com duas contas (ex. liderança + assessora) precisa do dedupe pela MESMA ficha; o "1:1" da intenção é a direção conta→ficha, que o campo único de relationship já garante. Rejeitado: unique (bloquearia o cenário real de papéis múltiplos com erro de duplicate key opaco). - Sync de identidade em update: Opções: sync 1-via (conta → ficha) quando mudou | sem sync (ficha é só do admin). Recomendação: sync —
/campanha/assessoresé onde a coordenação edita o staff; sem ele a C100 leria telefone obsoleto. Falha em conflito = fail-closed com a mensagem padrão da ficha. Rejeitado: sem sync (ficha obsoleta, contradiz o fluxo "edição" do aceite). - Assessores da liderança — acesso/validação: Opções: renomear+extrair
canAssign*/canManage*e a validação para módulo compartilhado | copiar nomes de dobradinha na liderança. Recomendação: extrair (3ª cópia da validação, 3º consumidor do acesso — DRY ≥3 call sites; nomes honestos). Municípios mantém o próprio (semântica de carteira). - Cap de assessores da liderança: espelhar o cap 10 da dobradinha (
MAX_ADVISORS_PER_LEADERSHIP) no bounded-delta da action — consistência com o padrão existente. - Ficha com e-mail stub: Opções: copiar
@criado.invalidpara a ficha | filtrar placeholders. Recomendação: filtrar (a ficha é a identidade pública; stub é artefato de conta de login).
Aceite de engenharia
- Aceite de produto da intenção ainda coberto (vínculo conta→ficha; assessores responsáveis na liderança; auth intacta; sem backfill)
- Invariantes AGENTS/engineering-standards (transações +
req.transactionID; overrideAccess justificado; líderes não leemestimatedVotes— não tocado; identificadores em inglês / labels pt-BR) - Testes de domínio previstos (int de
contactIdentity+ membership de assessores da liderança + extensão dos specs de advisor/access) - Migration aditiva commitada +
generate:types+ importmap se preciso; gates completos (tsc,lint,format:check,knip,check:cycles, unit+int, build)
Adiado com gatilho (triage pós-merge, 2026-08-09)
- S6 — ficha órfã em creates name-only concorrentes (sem lock no bare path): registrar lock ou dedupe quando houver o primeiro caso real de concorrência no bare path (staff/admin-only hoje — probabilidade baixa).
- S9 — lista de lideranças não carrega
advisors: o detail expõe; a coluna "Assessorado" da C100 (#495, já dependente) precisará do view-model de lista com o campo — resolver na execução da C100.
Explicitamente fora (triage pós-merge, 2026-08-09)
- S7 — unlink explícito é transitório (próxima edição re-vincula por phone): comportamento intencional do plano ("vínculo por criação/edição"), documentado no hook; descartado.
- S8 — create com contact explícito + phone divergente: escolha explícita do admin; descartado.
- S12 — drift de mensagem non-postgres: postgres-only na prática; descartado.