mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 02:23:07 +00:00
Simplify C6 supporter paths and register C8 scale/DRY follow-ups.
Apply the /simplify cleanup to the C6 commit (identity maps, dead branches, relationshipId reuse, native base64url, rowCount fix, canManageCampaignUsers reuse) and register the unimplemented debits as roadmap item C8 with its plan.
This commit is contained in:
parent
9fdbae05d7
commit
9ccfb3b704
11 changed files with 188 additions and 110 deletions
120
docs/plans/escala-dry-pos-c6.md
Normal file
120
docs/plans/escala-dry-pos-c6.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Escala e DRY pós-C6 (apoiadores / import / listas)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C8)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O C6 ([escala-dry-pos-c2.md](escala-dry-pos-c2.md)) entregou a escala do cadastro nominal de apoiadores: import bulk via drizzle na txn Payload, KPI agregado em SQL, token HMAC de import single-use, e shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). A passagem `/simplify` de 2026-07-19 aplicou limpezas pontuais (identity maps, dead branches, `relationshipId`, base64url nativo, `rowCount ?? 0`, reuso de `canManageCampaignUsers`) mas **deixou de fora** débitos que os revisores (quality / reuse / performance) marcaram como importantes e maiores que cleanup. Este plano é o registro canônico desses follow-ups — precedente direto de B5 (pós-B2) e C7 (pós-C3).
|
||||
|
||||
Sem este item, a importação funciona em volume baixo, mas: (a) o gargalo de import em escala volta a ser a aquisição de locks advisory (um round-trip por telefone), não o insert bulk; (b) a página de lista de apoiadores faz dois COUNTs filtrados por navegação; (c) a busca por nome/cidade é `ILIKE '%q%'` não-sargable; (d) o scaffold de tipos drizzle do bulk apaga a tipagem de colunas (já causou um bug `contactId` → `contact`); (e) o helper de erro de form adotado só por 2 forms permanece duplicado em ~6 `formActions` legados.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Import bulk de 5k apoiadores adquire todos os locks advisory de telefone em **um** round-trip (hoje até 5k round-trips sequenciais).
|
||||
- O insert bulk de contacts recupera IDs via `.returning()` (hoje re-`payload.find` por chunk).
|
||||
- Leituras de existência (contacts/supporters existentes) no bulk usam drizzle direto na mesma txn, sem o scaffolding do Local API.
|
||||
- A página de lista de apoiadores não faz dois COUNTs filtrados por navegação; o `total` do overview vem do `totalDocs` da lista, ou o overview é cacheado por `(user, filterState)` com TTL curto.
|
||||
- Busca por `q` em nome/cidade usa índice `pg_trgm` GIN (ou restringe a prefix/telefone) — deixa de ser seq scan por página.
|
||||
- O lookup de núcleos acessíveis do coordenador é memoizado por request (lista + overview compartilham uma query).
|
||||
- `chunk` / `requireTable` / `INSERT_CHUNK_SIZE` / scaffold de tipos drizzle vivem num helper compartilhado, reusado por `supporterImportBulk.ts` e `electionResultsImport.ts` (import TSE).
|
||||
- O scaffold de tipos drizzle não apaga a tipagem de colunas: assertion runtime dos nomes de coluna em `payload.db.tables.supporter` ao init **ou** `PostgresTransactionDatabase` expõe um `insert` tipado.
|
||||
- `getAccessibleNucleusIds` ganha uma variante sem `PayloadRequest` (ou `supporterListOverviewAggregate` reusa a existente), eliminando o lookup duplicado de escopo coordenador.
|
||||
- Todos os `formActions.ts` de `/campanha` restantes migram para `mapCampaignFormActionError` + `campaignFormFields` (hoje duplicados em ~6 arquivos).
|
||||
- Guardrails: `overrideAccess: false` com `user`; escritas multi-step continuam em `withPayloadTransaction`; locks advisory continuam xact-level; sem novo `Consent`; sem mudança de comportamento visível.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um item C8, fases ordenadas.** Mesmo racional do C6/C7: um ID de roadmap, PRs por fase. Ordem: perf do caminho quente (import) → perf de leitura (lista) → DRY de helpers → DRY de forms.
|
||||
- **Dependência dura de C6 (merge).** O código que C8 otimiza é o código do C6; não reabre o escopo de v1 do `supporter`/`supporterImportBatch`.
|
||||
- **Cortável se a base nominal permanecer pequena.** Fases 1–2 (perf de import/lista) só rendem com base real em volume; se a base ficar pequena até 16/08, adiar para Janela 3. Fases 3–4 (DRY) são baratas e reduzem drift — manter se houver folga.
|
||||
- **Sem migration de schema em Fases 1–3.** Fase 2 pode exigir uma migration pequena (`pg_trgm` GIN em `contact.name`/`contact.city`) — única migration do item.
|
||||
- **`postgresTransactionLocks.ts` é pré-existente e compartilhado** com o path single-create (`upsertContactByPhone`, `enforceUniqueContactPhone`). A Fase 1 troca o loop de locks por uma query única sem mudar o contrato `acquireContactPhoneLocks(payload, req, phones[])` — callers não mudam.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`acquireContactPhoneLocks` mantido, `drizzleBulk.ts`, `getAccessibleNucleusIds` variant, etc.), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **`pg_trgm` vs prefix-only para busca de nome?** `ILIKE '%q%'` precisa de trigram para ser sargable; prefix-only (`ILIKE 'q%'`) usa btree mas perde busca infix. **Recomendação:** `pg_trgm` GIN (a busca infix é o comportamento esperado pela UI hoje); migration pequena `add_contact_trgm_index`.
|
||||
- **Overview `total` derivado do `totalDocs` da lista vs cache?** Derivar elimina um COUNT mas acopla overview ao carregamento da lista (overview some se lista não carrega). **Recomendação:** derivar `total` de `result.totalDocs` (já computado pela lista) e rodar só os dois `FILTER` no overview; cache só se houver reclamação de latency.
|
||||
- **Assertion runtime de colunas vs widen `PostgresTransactionDatabase`?** Assertion é barata e pega o bug na inicialização; widen é mais limpo mas toca `postgresTransactionLocks.ts` (pré-existente). **Recomendação:** assertion runtime em `supporterImportBulk.ts` (escopo do item); widen fica como follow-up de plataforma.
|
||||
- **Migrar formActions de planos aqui (C8) ou em C7?** O plano do C6 anotou "mapper de form actions de planos ficou para C7". **Recomendação:** consolidar TODOS os formActions restantes em C8 Fase 4 (C8 é o "DRY pós-C6" e C6 introduziu os helpers); C7 Fase 3 continua dono dos helpers FormData/URL de território do actionPlan. Atualizar a nota cruzada no plano do C6 na hora do PR.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
F1["Fase 1 — Perf import<br/>locks em 1 RT · .returning · drizzle reads"]
|
||||
F2["Fase 2 — Perf lista<br/>1 COUNT · trgm · memo accessible nuclei"]
|
||||
F3["Fase 3 — DRY helpers<br/>drizzleBulk · getAccessibleNucleusIds variant · DrizzleTx assertion"]
|
||||
F4["Fase 4 — DRY forms<br/>migrar formActions restantes"]
|
||||
|
||||
C6["C6 apoiadores ✓"] --> F1
|
||||
F1 --> F2
|
||||
F2 --> F3
|
||||
F3 --> F4
|
||||
```
|
||||
|
||||
### Fase 1 — Perf do caminho quente de import
|
||||
|
||||
- **`acquireContactPhoneLocks`** (`src/utilities/contactPhoneInvariant.ts` / `postgresTransactionLocks.ts`): trocar o loop `for (const key of sortedKeys) await database.execute(...)` por uma query única `SELECT pg_advisory_xact_lock(hashtextextended(k, 0)) FROM unnest(ARRAY[...]) AS k` (ordenar para evitar deadlock). Contrato `acquireContactPhoneLocks(payload, req, phones[])` inalterado — callers (bulk, single-create, hook) não mudam.
|
||||
- **`bulkInsertSupporterImport`** (`src/utilities/supporterImportBulk.ts`): `tx.insert(contactTable).values(...).returning({ id: true, phone: true })` em vez do re-`payload.find` por chunk para recuperar IDs (elimina ~10 round-trips no cap de 5k). Widen o tipo `DrizzleTx` local para tipar `.returning()`.
|
||||
- Substituir os dois `payload.find` de existência (contacts por phone, supporters por contact_id) por `SELECT ... FROM contact WHERE phone = ANY($)` / `SELECT contact_id FROM supporter WHERE contact_id = ANY($) AND nucleus_id IS NULL` na mesma sessão drizzle da txn.
|
||||
|
||||
### Fase 2 — Perf de leitura da lista
|
||||
|
||||
- **`loadSupporterListOverviewData`** (`src/utilities/supporterPageData.ts`): derivar `total` do `result.totalDocs` da lista (já computado por Payload); o overview roda só os dois `COUNT(*) FILTER` (certo+tende, indeciso). Elimina um COUNT filtrado completo por navegação.
|
||||
- **`computeSupporterListOverviewAggregate`** (`src/utilities/supporterListOverviewAggregate.ts`): memoizar o `resolveAccessConstraint` (lookup de núcleos acessíveis do coordenador) por request — lista e overview compartilham a mesma query `electoralNucleus`.
|
||||
- **Migration** `add_contact_trgm_index`: `CREATE EXTENSION IF NOT EXISTS pg_trgm` + `CREATE INDEX ... USING gin (name gin_trgm_ops)` e `(city gin_trgm_ops)` em `contact`. Torna `ILIKE '%q%'` sargable. Única migration do item.
|
||||
- Avaliar restringir `q` a mínimo 2–3 chars no cliente (reduz seq scans inúteis) — opcional, sem migration.
|
||||
|
||||
### Fase 3 — DRY de helpers
|
||||
|
||||
- **`src/utilities/drizzleBulk.ts`** (novo): extrair `chunk`, `requireTable`, `INSERT_CHUNK_SIZE`, e o scaffold de tipos `DrizzleTx`/`PayloadDb` de `supporterImportBulk.ts` e `electionResultsImport.ts` (hoje byte-idênticos). Importar de ambos.
|
||||
- **`getAccessibleNucleusIds`** (`src/utilities/campaignAccess.ts`): adicionar variante sem `PayloadRequest` (ou parametrizar a existente com cache opcional) para que `supporterListOverviewAggregate` reuse em vez de re-implementar o lookup `coordinators contains user.id`.
|
||||
- **Type safety do bulk:** assertion runtime em `supporterImportBulk.ts` que `Object.keys(payload.db.tables.supporter)` contém as colunas esperadas (`contact`, `nucleus`, `consent`, `createdBy`, …) no init do módulo — pega o bug `contactId` vs `contact` antes do runtime. (Widen de `PostgresTransactionDatabase` fica como follow-up de plataforma.)
|
||||
|
||||
### Fase 4 — DRY de forms
|
||||
|
||||
- Migrar os `formActions.ts` restantes para `mapCampaignFormActionError` + `campaignFormFields` (hoje o ladder `FormDataBoundaryError` → `ZodError` → `validationFieldErrors` → safe-message está inlined em ~6 arquivos):
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/formActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts`
|
||||
- `src/app/(campaign)/campanha/convite/[token]/formActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/planos/formActions.ts` e `planos/[slug]/updateFormActions.ts`
|
||||
- Migrar `fieldError`/`firstError` local em `src/components/campaign/NucleusUpdateForm.tsx` e `CampaignInviteForm.tsx` para importar de `campaignFormFields.ts`.
|
||||
|
||||
**Migration:** Fases 1, 3, 4 sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** C6 Escala e DRY pós-C2 (código em `supporterImportBulk.ts` / `supporterListOverviewAggregate.ts` / `supporterPageData.ts` / `campaignFormActionError.ts`).
|
||||
- Reusa: `src/utilities/contactPhoneInvariant.ts`, `postgresTransactionLocks.ts`, `getPostgresTransactionDatabase`, `relationshipId`, `campaignAccess.ts` (`getAccessibleNucleusIds`, `canManageCampaignUsers`), `electionResultsImport.ts` (para `drizzleBulk.ts`), C6 plano [escala-dry-pos-c2.md](escala-dry-pos-c2.md).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Reabrir decisões de v1 do C6 (token HMAC vs opaque, `supporterImportBatch` como staging, `skipContactPhoneInvariant` fail-closed) — [escala-dry-pos-c2.md](escala-dry-pos-c2.md).
|
||||
- Helpers FormData/URL de território do actionPlan — C7 Fase 3 / [escala-dry-pos-c3.md](escala-dry-pos-c3.md).
|
||||
- Camada de zonas no mapa, coroplético — B3/B4.
|
||||
- Widen de `PostgresTransactionDatabase` para expor `insert` tipado — follow-up de plataforma (a assertion runtime da Fase 3 cobre o bug).
|
||||
- Notificações / push — D2.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` — Trilha C, item C8; sequência Janela 2
|
||||
- [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — C6 entregue; precedente do formato
|
||||
- [escala-dry-pos-b2.md](escala-dry-pos-b2.md) — precedente B5 (pós-B2 simplify)
|
||||
- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — precedente C7 (pós-C3 simplify)
|
||||
- Review `/simplify` 2026-07-19 (quality / reuse / performance) — origem das fases
|
||||
- `src/utilities/supporterImportBulk.ts` — bulk insert (locks, .returning, drizzle reads)
|
||||
- `src/utilities/postgresTransactionLocks.ts` — `acquireContactPhoneLocks` (loop por telefone)
|
||||
- `src/utilities/supporterListOverviewAggregate.ts` — aggregate SQL + `resolveAccessConstraint`
|
||||
- `src/utilities/supporterPageData.ts` — `loadSupporterListOverviewData` (duplo COUNT)
|
||||
- `src/utilities/electionResultsImport.ts` — `chunk`/`requireTable`/`INSERT_CHUNK_SIZE` duplicados
|
||||
- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds`
|
||||
- `src/utilities/campaignFormActionError.ts` / `campaignFormFields.ts` — helpers adotados por 2 forms
|
||||
- AGENTS.md — `overrideAccess: false`, transações, locks advisory xact-level, naming
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
|
||||
|
||||
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
|
||||
|
||||
|
|
@ -86,6 +86,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
|
|||
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
|
||||
| C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) |
|
||||
| C7 Escala e DRY pós-C3 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6) | [escala-dry-pos-c3.md](plans/escala-dry-pos-c3.md) |
|
||||
| C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) |
|
||||
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
|
||||
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
|
||||
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
|
||||
|
|
@ -127,6 +128,7 @@ flowchart TD
|
|||
C6["C6 Escala e DRY pós-C2<br/>(import/listas/forms)"]
|
||||
C3["C3 Eventos / agenda de mobilização ✓"]
|
||||
C7["C7 Escala e DRY pós-C3<br/>(território/contato/leituras)"]
|
||||
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY)"]
|
||||
C4["C4 Demandas"]
|
||||
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
|
||||
end
|
||||
|
|
@ -160,11 +162,12 @@ flowchart TD
|
|||
C2 --> C6
|
||||
C2 --> C5
|
||||
C6 -.escala da base.-> C5
|
||||
C6 --> C8
|
||||
D1 --> D2
|
||||
JUR -.chave de push.-> D2
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2) e D2 push (D1 pronto; falta a chave jurídica).
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica).
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
|
|
@ -185,29 +188,30 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
|
|||
| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
|
||||
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
|
||||
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado 2026-07-19 — Fases 1–5; aguarda deploy)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7 |
|
||||
| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7 |
|
||||
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7 |
|
||||
| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6 |
|
||||
| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
|
||||
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
|
||||
| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8 |
|
||||
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7 |
|
||||
|
||||
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- |
|
||||
| 12 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 |
|
||||
| 13 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 |
|
||||
| 14 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 |
|
||||
| 15 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 |
|
||||
| 16 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 17 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 |
|
||||
| 13 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 |
|
||||
| 14 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 |
|
||||
| 15 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 |
|
||||
| 16 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 |
|
||||
| 17 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 18 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 |
|
||||
|
||||
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 18 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 19 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 20 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
|
||||
| 21 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
|
||||
| 19 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 20 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 21 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
|
||||
| 22 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
|
||||
|
||||
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
|
||||
|
||||
|
|
@ -216,7 +220,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
|
|||
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
|
||||
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
|
||||
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2 e C7 fases 1–2 (DRY de UI) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2 e C8 fases 3–4 (DRY de helpers/forms) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
verifySupporterImportToken,
|
||||
} from '@/utilities/supporterImportToken'
|
||||
import {
|
||||
isPreviewErrorRow,
|
||||
isSupporterImportOkRow,
|
||||
type SupporterImportPreviewResult,
|
||||
type SupporterImportPreviewRow,
|
||||
|
|
@ -50,18 +51,6 @@ const MAX_IMPORT_ROWS = 5000
|
|||
/** On-screen preview is capped; the full ok set is staged server-side (Phase 5). */
|
||||
const SUPPORTER_IMPORT_SAMPLE_SIZE = 100
|
||||
|
||||
const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean =>
|
||||
row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone'
|
||||
|
||||
const importStatusCsvValue: Record<SupporterImportPreviewRow['status'], string> = {
|
||||
ok: 'ok',
|
||||
duplicado_pelo_telefone: 'duplicado_pelo_telefone',
|
||||
telefone_invalido: 'telefone_invalido',
|
||||
municipio_nao_reconhecido: 'municipio_nao_reconhecido',
|
||||
nome_invalido: 'nome_invalido',
|
||||
intencao_invalida: 'intencao_invalida',
|
||||
}
|
||||
|
||||
const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`
|
||||
|
||||
const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => {
|
||||
|
|
@ -70,7 +59,7 @@ const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]):
|
|||
.filter(isPreviewErrorRow)
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${importStatusCsvValue[row.status]}`,
|
||||
`${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${row.status}`,
|
||||
)
|
||||
.join('\n')
|
||||
return header + body
|
||||
|
|
@ -600,12 +589,7 @@ export const confirmSupporterImportRecord = async (
|
|||
payload,
|
||||
req,
|
||||
actorID: currentActor.id,
|
||||
rows: batch.okRows.map((row) => ({
|
||||
telefone: row.telefone,
|
||||
nome: row.nome,
|
||||
municipio: row.municipio,
|
||||
intencao: row.intencao,
|
||||
})),
|
||||
rows: batch.okRows,
|
||||
registrationConsent,
|
||||
voteIntentionConsent,
|
||||
consentNote,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
|
|||
// honored inside an active transaction — without one the xact-level advisory
|
||||
// lock cannot be held, so we run the full check regardless of the caller's claim.
|
||||
if (req.context?.skipContactPhoneInvariant === true) {
|
||||
if (req.transactionID === undefined || req.transactionID === null) {
|
||||
if (req.transactionID == null) {
|
||||
throw new APIError(
|
||||
'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.',
|
||||
500,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Access, CollectionConfig } from 'payload'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utilities/campaignAccess'
|
||||
import { canManageCampaignUsers } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* Short-lived server-side staging for the supporter CSV import wizard (roadmap
|
||||
|
|
@ -10,16 +10,11 @@ import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utili
|
|||
* Action boundary twice.
|
||||
*
|
||||
* The collection is hidden from the admin nav and restricted to `geral` /
|
||||
* Payload-admin; the import actions interact with it via the Local API with
|
||||
* `overrideAccess: true`. Rows hold transient supporter PII and must be
|
||||
* purged on consume or after `expiresAt` (lazy cleanup + sweep).
|
||||
* Payload-admin (same policy as `campaignUser` management); the import actions
|
||||
* interact with it via the Local API with `overrideAccess: true`. Rows hold
|
||||
* transient supporter PII and must be purged on consume or after `expiresAt`
|
||||
* (lazy cleanup + sweep).
|
||||
*/
|
||||
const canAccessImportBatch: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
const fresh = await getFreshCampaignUser(req)
|
||||
return isCampaignGeneral(fresh)
|
||||
}
|
||||
|
||||
export const SupporterImportBatch: CollectionConfig = {
|
||||
slug: 'supporterImportBatch',
|
||||
labels: {
|
||||
|
|
@ -33,10 +28,10 @@ export const SupporterImportBatch: CollectionConfig = {
|
|||
defaultColumns: ['batchId', 'actor', 'expiresAt'],
|
||||
},
|
||||
access: {
|
||||
create: canAccessImportBatch,
|
||||
read: canAccessImportBatch,
|
||||
update: canAccessImportBatch,
|
||||
delete: canAccessImportBatch,
|
||||
create: canManageCampaignUsers,
|
||||
read: canManageCampaignUsers,
|
||||
update: canManageCampaignUsers,
|
||||
delete: canManageCampaignUsers,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
isPreviewErrorRow,
|
||||
type SupporterImportPreviewResult,
|
||||
type SupporterImportPreviewRow,
|
||||
} from '@/utilities/supporterImport'
|
||||
|
|
@ -45,9 +46,6 @@ const importStatusLabels: Record<SupporterImportPreviewRow['status'], string> =
|
|||
|
||||
const steps = ['Upload', 'Conferir prévia', 'Confirmação'] as const
|
||||
|
||||
const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean =>
|
||||
row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone'
|
||||
|
||||
const downloadErrorReport = (errorReportCsv: string) => {
|
||||
const blob = new Blob([errorReportCsv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
|
@ -196,7 +194,7 @@ export const SupporterImportWizard = () => {
|
|||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => preview && downloadErrorReport(preview.errorReportCsv)}
|
||||
onClick={() => downloadErrorReport(preview.errorReportCsv)}
|
||||
>
|
||||
Baixar relatório de erros
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -30,10 +30,7 @@ export const mapCampaignFormActionError = <Values>(
|
|||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error), values, revision }
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
safeMessages.includes(error.message as (typeof safeMessages)[number])
|
||||
) {
|
||||
if (error instanceof Error && safeMessages.includes(error.message)) {
|
||||
return { message: error.message, values, revision }
|
||||
}
|
||||
return { message: genericMessage, values, revision }
|
||||
|
|
|
|||
|
|
@ -49,3 +49,6 @@ export type SupporterImportPreviewResult = {
|
|||
export const isSupporterImportOkRow = (
|
||||
row: SupporterImportPreviewRow,
|
||||
): row is SupporterImportOkRow => row.status === 'ok'
|
||||
|
||||
export const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean =>
|
||||
row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
|||
import type { ConsentDescriptor } from '@/utilities/campaignConsent'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
export type SupporterImportBulkRow = {
|
||||
telefone: string
|
||||
|
|
@ -45,7 +46,7 @@ const INSERT_CHUNK_SIZE = 500
|
|||
|
||||
const chunk = <T>(rows: readonly T[], size: number): T[][] => {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size) as T[])
|
||||
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -187,30 +188,21 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
}
|
||||
|
||||
const contactIDs = [...new Set(contactIdByPhone.values())]
|
||||
const existingSupporters =
|
||||
contactIDs.length > 0
|
||||
? await payload.find({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }],
|
||||
},
|
||||
depth: 0,
|
||||
limit: contactIDs.length,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
: null
|
||||
const existingSupporters = await payload.find({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }],
|
||||
},
|
||||
depth: 0,
|
||||
limit: contactIDs.length,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const supporterContactIDs = new Set(
|
||||
(existingSupporters?.docs ?? [])
|
||||
.map((doc) =>
|
||||
typeof doc.contact === 'number'
|
||||
? doc.contact
|
||||
: typeof doc.contact === 'object' && doc.contact !== null
|
||||
? doc.contact.id
|
||||
: null,
|
||||
)
|
||||
existingSupporters.docs
|
||||
.map((doc) => relationshipId(doc.contact))
|
||||
.filter((id): id is number => id !== null),
|
||||
)
|
||||
|
||||
|
|
@ -236,7 +228,6 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
|
||||
let created = 0
|
||||
for (const batch of chunk(candidateEntries, INSERT_CHUNK_SIZE)) {
|
||||
if (batch.length === 0) continue
|
||||
const result = await tx
|
||||
.insert(supporterTable)
|
||||
.values(
|
||||
|
|
@ -249,7 +240,9 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
}),
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
created += Number(result.rowCount ?? batch.length)
|
||||
// rowCount reflects inserted rows only (conflicts are skipped). Fall back to 0
|
||||
// rather than batch.length so a null rowCount never silently over-counts.
|
||||
created += Number(result.rowCount ?? 0)
|
||||
}
|
||||
|
||||
return { created, skipped, errors }
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export type SupporterImportOkRow = {
|
|||
intencao?: SupporterVoteIntention
|
||||
}
|
||||
|
||||
export type SupporterImportBatch = {
|
||||
export type StagedSupporterImportBatch = {
|
||||
actorID: number
|
||||
expiresAt: number
|
||||
okRows: SupporterImportOkRow[]
|
||||
|
|
@ -27,7 +27,7 @@ const IMPORT_TOKEN_TTL_MS = 10 * 60 * 1000
|
|||
export const storeSupporterImportBatch = async (
|
||||
payload: Payload,
|
||||
batchId: string,
|
||||
batch: SupporterImportBatch,
|
||||
batch: StagedSupporterImportBatch,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
await payload.create({
|
||||
|
|
@ -49,7 +49,7 @@ export const loadSupporterImportBatch = async (
|
|||
batchId: string,
|
||||
expectedActorID: number,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<SupporterImportBatch> => {
|
||||
): Promise<StagedSupporterImportBatch> => {
|
||||
const result = await payload.find({
|
||||
collection: BATCH_COLLECTION,
|
||||
where: { batchId: { equals: batchId } },
|
||||
|
|
@ -60,21 +60,15 @@ export const loadSupporterImportBatch = async (
|
|||
req,
|
||||
})
|
||||
const doc = result.docs[0] as
|
||||
| { id: number; actor: unknown; expiresAt?: string; rows?: unknown }
|
||||
| { id: number; actor: number; expiresAt: string; rows: unknown }
|
||||
| undefined
|
||||
if (!doc) throw new Error('Lote de importação não encontrado ou expirado.')
|
||||
|
||||
const actorID =
|
||||
typeof doc.actor === 'number'
|
||||
? doc.actor
|
||||
: typeof doc.actor === 'object' && doc.actor !== null && 'id' in doc.actor
|
||||
? (doc.actor as { id: number }).id
|
||||
: null
|
||||
if (actorID !== expectedActorID) {
|
||||
if (doc.actor !== expectedActorID) {
|
||||
throw new Error('Lote de importação inválido.')
|
||||
}
|
||||
|
||||
const expiresAtMs = doc.expiresAt ? Date.parse(doc.expiresAt) : NaN
|
||||
const expiresAtMs = Date.parse(doc.expiresAt)
|
||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
||||
throw new Error('Lote de importação expirado.')
|
||||
}
|
||||
|
|
@ -114,14 +108,6 @@ export const deleteSupporterImportBatch = async (
|
|||
})
|
||||
}
|
||||
|
||||
const base64url = (buffer: Buffer): string =>
|
||||
buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
const base64urlToBuffer = (value: string): Buffer => {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(padded, 'base64')
|
||||
}
|
||||
|
||||
const getSecret = (): string => {
|
||||
const secret = process.env.PAYLOAD_SECRET
|
||||
if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o token de importação.')
|
||||
|
|
@ -140,7 +126,7 @@ export type SignedImportToken = {
|
|||
export const issueSupporterImportToken = (actorID: number): SignedImportToken => {
|
||||
const batchId = randomUUID()
|
||||
const expiresAt = Date.now() + IMPORT_TOKEN_TTL_MS
|
||||
const sig = base64url(signature(batchId, actorID, expiresAt))
|
||||
const sig = signature(batchId, actorID, expiresAt).toString('base64url')
|
||||
return { token: `${batchId}.${expiresAt}.${sig}`, batchId, expiresAt }
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +142,7 @@ export const verifySupporterImportToken = (
|
|||
throw new Error('Token de importação expirado.')
|
||||
}
|
||||
const expected = signature(batchId, actorID, expiresAt)
|
||||
const provided = base64urlToBuffer(sig)
|
||||
const provided = Buffer.from(sig, 'base64url')
|
||||
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
||||
throw new Error('Token de importação inválido.')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ const resolveAccessConstraint = async (
|
|||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const ids = result.docs
|
||||
.map((doc) => (typeof doc.id === 'number' ? doc.id : Number(doc.id)))
|
||||
.filter((id) => Number.isSafeInteger(id))
|
||||
const ids = result.docs.map((doc) => doc.id)
|
||||
|
||||
return { kind: 'nucleusSet', ids }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue