18 KiB
Escala e DRY pós-C2 (apoiadores + listas)
Status: implementado e mesclado em main (2026-07-19)
Atualizado em: 2026-07-19
Item do roadmap: docs/roadmap.md (Trilha C, item C6)
Responsável: —
Contexto
O C2 (cadastro-nominal-apoiadores.md) entregou a engenharia do cadastro nominal: collection supporter, actions de create/intenção/import/remover, UI em /campanha/apoiadores. A terceira passagem de /simplify (2026-07-18) limpou dead code e churns baratos, mas deixou de fora refactors e ganhos de escala que os revisores marcaram como importantes e fora do escopo de “cleanup pontual”:
- Extrair shells compartilhados com a lista de núcleos (
nucleusUi/NucleusFilters/NucleusPagination). - Unificar
fieldError/ mapeador de erro de form actions (hoje clone de leadership). - Import CSV em massa com insert via drizzle + bypass do invariante de telefone quando o caller já segura os locks.
- Preview/confirm sem round-trip de até 5k linhas na action.
- KPIs da lista com um único agregado SQL em vez de 3×
count. - UX alinhada:
StrictComboboxno município do create eAlertDialogno “Remover dados”.
Este plano é o registro canônico desses follow-ups. Sem ele, a base nominal funciona para volume baixo, mas o import de 5k linhas e o DRY com núcleos ficam como dívida implícita.
Objetivos
- Listas de núcleos e apoiadores compartilham primitivos de URL/paginação/filtro e helpers de form error, sem regressão nas rotas já entregues.
- Import CSV de até
MAX_IMPORT_ROWS(5000) completa dentro do timeout Vercel/DB, com locks de telefone corretos e sem N× hooks redundantes emContact. - Preview/confirm não serializam o conjunto completo
okduas vezes pela fronteira da Server Action. - Overview de apoiadores: um round-trip de agregação (ou reuso de
totalDocs+ um agregado) em vez de trêscountparalelos semreqcompartilhado. - Create de apoiador usa o mesmo
StrictComboboxde município dos filtros/núcleos; remoção destrutiva usaAlertDialog(padrãoArchiveNucleusDialog). - Guardrails:
overrideAccess: falsequando houveruser; escrita multi-collection continua transacional; qualquer bypass deenforceUniqueContactPhonesó sob locks já adquiridos na mesma txn; sem novoConsent/ sem collection nova (salvo staging efêmero de import, se a Fase 4 exigir).
Decisões travadas
- Um item C6, cinco fases ordenadas. Não criar seis itens de roadmap; o custo de coordenação supera o benefício. Fases podem ser PRs separados, mas a ordem abaixo é a recomendada (UX barata → DRY → KPI → import pesado → preview token).
- Dependência dura de C2. Este item só faz sentido com o código C2 mesclado; não reabre o escopo de v1 do cadastro nominal.
- Cortável se a base permanecer pequena. Se a campanha não importar milhares de linhas antes de 16/08, as fases 3–5 podem escorregar; as fases 1–2 (UX + DRY) continuam baratas e valem a pena cedo.
- Bypass do invariante de telefone é opt-in por
context, nunca global. Precedente: locks emcontactPhoneInvariant.ts/ hookenforceUniqueContactPhoneemContact.ts. Caller que já rodouacquireContactPhoneLocksna mesma txn pode passarcontext: { skipContactPhoneInvariant: true }; o hook só respeita se a txn já segura os locks (falha fechado caso contrário — validar na implementação). - Bulk insert segue o padrão TSE seed.
payload.db.drizzleem txn (comopnpm db:seed:tse/scripts/seed-tse-results.mjs), comON CONFLICT/ tratamento de uniquesupporter_contact_nucleus, não milhares depayload.createsob um únicoacquireContactPhoneLocksde 5k phones. - Preview token: sem Redis novo no v1 do C6. Preferir blob assinado curto (HMAC + expiry) ou staging em memória/
unstable_cachecom TTL curto no processo; se insuficiente na Vercel, staging em tabela efêmera com TTL + job de limpeza. Decisão final na Fase 4 com medição. - i18n e naming (AGENTS.md): identificadores em inglês (
campaignListUrl,CampaignListPagination,mapCampaignFormActionError,skipContactPhoneInvariant,importToken), strings visíveis em pt-BR.
Questões em aberto
- Onde vive o staging do preview? Blob assinado vs tabela
supporterImportBatchvs cache em processo. Recomendação: começar com blob assinado (sem migration); só promover a tabela se o payload + HMAC estourar limites práticos da action. - Chunk size do bulk import. Recomendação: 200–500 rows por commit interno, com progresso retornado ao wizard; calibrar com teste de carga local contra
teqo_test. - KPI: drizzle raw vs Payload
count+ group. Recomendação: umSELECT … COUNT(*) FILTER(ouGROUP BY vote_intention) via drizzle com o mesmowherede acesso quebuildSupporterListWhere+ constraint decanReadSupporter; reusarresult.totalDocsda listagem para o total quando a página já carregou o find. - Extrair filter shell agora ou só URL + pagination? Recomendação: extrair URL helpers + pagination no primeiro PR; filter shell (
valuesRef+useTransition+ disclosure) no segundo — o shell é o mais arriscado para regressão de núcleos. - Migrar leadership forms para o mapper compartilhado no mesmo PR? Recomendação: sim para o mapper/
fieldError(poucas linhas); não obrigar leadership a mudar copy de mensagens.
Abordagem proposta
flowchart TD
F1["Fase 1 — UX<br/>StrictCombobox + AlertDialog"]
F2["Fase 2 — DRY listas/forms<br/>campaignListUrl · pagination · fieldError"]
F3["Fase 3 — KPI agregado SQL"]
F4["Fase 4 — Import bulk<br/>drizzle + skipContactPhoneInvariant"]
F5["Fase 5 — Preview token<br/>sem round-trip 5k"]
C2["C2 apoiadores ✓ eng."] --> F1
F1 --> F2
F2 --> F3
F3 --> F4
F4 --> F5
Fase 1 — UX alinhada (barata)
SupporterForm: município viaStrictCombobox+municipalityComboboxOptions()(já usado emSupporterFilters/NucleusFilters); validação continua emresolveBahiaMunicipality/optionalBahiaCity.RemoveSupporterDataButton: trocarwindow.confirmporAlertDialogno padrão deArchiveNucleusDialog.tsx.
Fase 2 — DRY com núcleos / leadership
src/utilities/campaignListUrl.ts(novo):firstValue,normalizedText,strictDecimalInteger,inspectRaw*ListParams, shape deresolve*ListUrl— hoje clonados emnucleusUi.tsesupporterUi.ts. Domínio (parse*ListParams,build*ListWhere) permanece nos módulos de domínio.CampaignListPagination:SupporterPaginationjá reusagetNucleusPaginationPages; extrair o markup Pagination compartilhado comhrefForPage.fieldError/errorProps: módulo pequeno (ex.src/utilities/campaignFormFields.ts) usado porLeadershipFormeSupporterForm.mapCampaignFormActionError: consolidar o catch FormDataBoundaryError → Zod → safelist → genérico denovo/formActions.ts,[id]/formActions.tseleadershipFormActions.ts.- Filter shell (opcional neste PR ou PR seguinte):
CampaignListFiltersShella partir deNucleusFilters/SupporterFilters.
Fase 3 — KPI da lista
loadSupporterListOverviewData: substituir 3×payload.countpor um agregado SQL (drizzle) com o mesmo escopo de acesso; alinhar total comtotalDocsda listagem quando possível.- Opcional:
createLocalRequma vez e passarreqaos loaders da página para reutilizar cache de access emreq.context.
Fase 4 — Import em escala
confirmSupporterImportRecord: após locks + lookup em batch, insert de contacts/supporters viapayload.db.drizzle(espelhar seed TSE), com tratamento de conflito unique.Contacthook: honrarcontext.skipContactPhoneInvariantsomente quando seguro.- Chunking /
maxDurationse um único txn de 5k ainda estourar timeout.
Fase 5 — Preview sem round-trip
previewSupporterImport: retorna{ counts, sampleRows, importToken }(amostra para UI; hojeslice(0, 100)).confirmSupporterImport: aceita token (ou id de batch) em vez do array completo de rowsok.SupporterImportWizard: para de guardar/reenviar todas as rows.
Migration: só se a Fase 5 escolher tabela de staging — pnpm migrate:create add_supporter_import_batch (TTL + cleanup). Fases 1–4: sem migration de schema de produto.
Dependências
- Dura: C2 cadastro nominal (código em
supporter*//campanha/apoiadores). - Suave: nenhuma. Produção com dados reais ainda espera Onda 0 (Consent keys) — C6 não cria chave nova.
- Reusa:
nucleusUi.ts,NucleusFilters.tsx,NucleusPagination.tsx,LeadershipForm.tsx,ArchiveNucleusDialog.tsx,contactPhoneInvariant.ts,Contact.ts, padrão drizzle do seed TSE,territoryComboboxOptions.ts.
Não escopo
- Reabrir decisões de v1 do C2 (telefone obrigatório, kit mínimo, import só
geral, etc.) — cadastro-nominal-apoiadores.md. - Agregado de apoiadores no overview de núcleos — follow-up de B1.
- GOTV / dia D — C5.
- Escala/DRY de planos de ação (
actionPlan) — C7 / escala-dry-pos-c3.md (consomecampaignListUrlda Fase 2 deste plano quando existir). - Push/notificações — D2 / notifications.md.
- Índices
pg_trgm/ FTS emcontact(mencionado na review de perf; só se busca ILIKE virar gargalo medido).
Referências
docs/roadmap.md— Trilha C, item C6; sequência Janela 2- cadastro-nominal-apoiadores.md — C2 v1
- Review
/simplify2026-07-18 (quality / reuse / performance) — origem da lista de fases src/app/(campaign)/campanha/actions/supporter.ts— preview/confirm importsrc/utilities/supporterUi.ts/supporterPageData.ts— lista + KPIssrc/utilities/nucleusUi.ts/src/components/campaign/NucleusFilters.tsx/NucleusPagination.tsx— padrões a extrairsrc/components/campaign/LeadershipForm.tsx/ArchiveNucleusDialog.tsx— fieldError / AlertDialogsrc/collections/Contact.ts—enforceUniqueContactPhonescripts/seed-tse-results.mjs— bulk drizzle em txn- AGENTS.md —
overrideAccess: false, transações, Contact + locks, naming
Implementação (2026-07-19)
Todas as cinco fases foram implementadas e validadas localmente (tsc --noEmit, pnpm lint, pnpm test:int, pnpm test:unit) contra teqo_test. Sem regressões nas rotas de núcleos/apoiadores/planos já entregues.
Fase 1 — UX alinhada
SupporterFormagora usaStrictCombobox+municipalityComboboxOptions()no município (validação continua emoptionalBahiaCity).RemoveSupporterDataButtontrocouwindow.confirmporAlertDialogno padrão deArchiveNucleusDialog.tsx.
Fase 2 — DRY com núcleos / leadership
src/utilities/campaignListUrl.ts(novo):firstValue,normalizedText,strictDecimalInteger,inspectRawListParams,resolveListUrl,buildListHref.nucleusUi.ts,supporterUi.tseactionPlanUi.tsdelegam para ele (domínio permanece nos módulos de domínio).src/components/campaign/CampaignListPagination.tsx(novo) +getPaginationPages: substituiNucleusPagination,SupporterPaginationeActionPlanPagination(os três foram removidos). Listagens de núcleos/apoiadores/planos e o teste unitáriocampaignNucleusUiapontam para o novo componente.src/utilities/campaignFormFields.ts(novo):fieldError/errorPropscompartilhados porSupporterFormeLeadershipForm(cada form liga umerrorPropslocal ao seu prefixo de id).src/utilities/campaignFormActionError.ts(novo):mapCampaignFormActionErrorconsolida FormDataBoundaryError → Zod → safelist → genérico.apoiadores/novo/formActions.ts,apoiadores/[id]/formActions.tsenucleos/[slug]/leadershipFormActions.tsusam o mapper.
Fase 3 — KPI da lista
src/utilities/supporterListOverviewAggregate.ts(novo):computeSupporterListOverviewAggregatefaz um únicoSELECT COUNT(*) FILTER (...)via drizzle na sessão da txn Payload, espelhandobuildSupporterListWhere+ a constraint de acesso (nucleus_id IN (...)para coordenador, sem constraint para geral,nonepara liderança).loadSupporterListOverviewDatapassou de 3×payload.countpara um round-trip. Testes int cobrem geral, coordenador (escopo + filtro de cidade) e base vazia.
Fase 4 — Import em escala
src/utilities/supporterImportBulk.ts(novo):bulkInsertSupporterImportinsere contacts e supporters viapayload.db.drizzlena sessão da txn Payload (getPostgresTransactionDatabase), em chunks de 500, comON CONFLICT DO NOTHINGno índice unique(contact_id, nucleus_id). Substitui até 10kpayload.createpor poucos inserts bulk.src/collections/Contact.ts:enforceUniqueContactPhonehonracontext.skipContactPhoneInvariantfail-closed — só respeita quandoreq.transactionIDestá ativo (sem txn, os locks xact-level não podem estar segurados → throw).upsertContactByPhonepassa o context (o caller já segurou os locks), eliminando o re-lock/re-check redundante do path single-create.
Fase 5 — Preview sem round-trip
src/collections/SupporterImportBatch.ts(novo, admin hidden, acessogeral/admin) + migration20260719_011015_add_supporter_import_batch: staging efêmero do conjuntookno servidor.src/utilities/supporterImportToken.ts(novo): token HMAC-SHA256(${batchId}.${actorID}.${expiresAt}, PAYLOAD_SECRET) comtimingSafeEquale TTL 10 min.previewSupporterImportTextagora retorna{ counts, sampleRows (100), errorReportCsv, importToken }e estageia o conjuntookemsupporterImportBatch.confirmSupporterImportRecordaceitaimportToken(schema trocourowsporimportToken), verifica HMAC + ator + expiração, consome o lote (single-use, delete após commit) e alimenta o bulk insert. O wizard parou de guardar/reenviar as rows.- Testes int: fluxo preview→confirm; token tampered (assinatura alterada) rejeitado; token de outro ator rejeitado; reuso de token consumido rejeitado.
Achados da auditoria (decisões de implementação)
- Nomes de coluna drizzle para relationships NÃO levam sufixo
Id. Empayload.db.tables.supporter, as colunas de relationship sãocontact,nucleus,consent,voteIntentionConsent,createdBy— nãocontactId, etc. O insert bulk inicial usoucontactId/createdByIde o drizzle silenciou essas chaves (geroudefault→ violação NOT NULL). Corrigido para os nomes sem sufixo. Lição para C7 e futuros inserts bulk: inspecionarObject.keys(payload.db.tables.<slug>)antes de montar as rows. payload-preferencesnão serve de staging genérico. Tem campouser(relationship users/campaignUser) obrigatório e validado embeforeChange, e semântica de preferência por usuário. Tentar usá-lo para o lote de importação falhou comValidationError: User. Decidido pela tabela dedicadasupporterImportBatch(migration), como o plano já previa como fallback. Não há Redis novo no v1 do C6.- Bulk insert deve rodar na txn Payload existente, não numa txn drizzle nova. O seed TSE (
electionResultsImport.ts) abredbAdapter.drizzle.transaction(...)própria; para C4 isso quebraria a atomicidade com os locks advisory. UsadogetPostgresTransactionDatabase(payload, req)para pegar a sessão drizzle vinculada à txn Payload. skipContactPhoneInvarianté fail-closed por txn, não por introspecção de lock. Não há maneira barata de perguntar ao PG "seguro o lock X?"; o proxy usado éreq.transactionIDativo (o lockpg_advisory_xact_locké xact-level e só existe dentro de uma txn). Sem txn + flag setado → throw 500.supporter.beforeChange(coexistência com liderança) é no-op para import sem núcleo. O hook só dispara quandocontactIDenucleusIDestão presentes; import cria supporters sem núcleo, então o bypass via drizzle insert é seguro.createdByé setado explicitamente no row bulk.- Escopo do mapper de form errors:
ActionPlanPaginatione os forms de planos também consomemcampaignListUrl/CampaignListPagination. O mapper de form actions de planos (planos/formActions.ts,planos/[slug]/updateFormActions.ts) ficou de fora do escopo original do C7 (copy/mensagens diferentes) mas foi consolidado pelo C8 (escala-dry-pos-c6.md), junto com os demaisformActionsrestantes — C7 Fase 3 manteve apenas os helpers de FormData/URL de território doactionPlan.
Simplify (2026-07-19)
Passagem /simplify sobre o commit do C6 aplicou limpezas pontuais (-94/+48 linhas) sem mudar comportamento: remoção de identity maps e re-maps (importStatusCsvValue, re-mapeamento de batch.okRows), colapso de branches mortos (typeof doc.actor === 'object'), reuso de helpers existentes (relationshipId, canManageCampaignUsers), base64url nativo do Node no token HMAC, simplificação do mapeamento doc.id no aggregate, remoção de guarda redundante no wizard, e req.transactionID == null. Correção de bug latente: created += Number(result.rowCount ?? batch.length) → ?? 0 (over-count em conflito). Renomeado tipo local StagedSupporterImportBatch para não colidir com o tipo Payload gerado. Validado com tsc --noEmit, pnpm lint, pnpm test:int, pnpm test:unit e scan Aikido. Os débitos de escala/DRY que os revisores marcaram como importantes e maiores que cleanup (locks bulk em 1 round-trip, .returning(), leituras drizzle diretas, pg_trgm, helper drizzleBulk.ts compartilhado, assertion de colunas, migração dos formActions restantes) foram registrados como item C8 — escala-dry-pos-c6.md.