Add C6 scale/DRY for supporters: bulk import, KPI aggregate, shared shells.
Phase 1 UX: StrictCombobox municipality in SupporterForm, AlertDialog in RemoveSupporterDataButton. Phase 2 DRY: campaignListUrl + CampaignListPagination + campaignFormFields + mapCampaignFormActionError shared across nuclei, supporters and action plans. Phase 3: single SQL aggregate for supporter list KPIs via drizzle in the Payload txn. Phase 4: bulk drizzle insert for CSV import inside the existing Payload txn with ON CONFLICT DO NOTHING, plus a fail-closed skipContactPhoneInvariant context flag. Phase 5: HMAC-signed single-use import token backed by a new supporterImportBatch staging collection so the full ok-row set never crosses the Server Action boundary twice.
This commit is contained in:
parent
8e0ee43844
commit
9fdbae05d7
40 changed files with 10712 additions and 668 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-C2 (apoiadores + listas)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-18
|
||||
Status: implementado (Fases 1–5)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C6)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -121,3 +121,38 @@ flowchart TD
|
|||
- `src/collections/Contact.ts` — `enforceUniqueContactPhone`
|
||||
- `scripts/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
|
||||
- `SupporterForm` agora usa `StrictCombobox` + `municipalityComboboxOptions()` no município (validação continua em `optionalBahiaCity`).
|
||||
- `RemoveSupporterDataButton` trocou `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`.
|
||||
|
||||
### Fase 2 — DRY com núcleos / leadership
|
||||
- `src/utilities/campaignListUrl.ts` (novo): `firstValue`, `normalizedText`, `strictDecimalInteger`, `inspectRawListParams`, `resolveListUrl`, `buildListHref`. `nucleusUi.ts`, `supporterUi.ts` e `actionPlanUi.ts` delegam para ele (domínio permanece nos módulos de domínio).
|
||||
- `src/components/campaign/CampaignListPagination.tsx` (novo) + `getPaginationPages`: substitui `NucleusPagination`, `SupporterPagination` e `ActionPlanPagination` (os três foram removidos). Listagens de núcleos/apoiadores/planos e o teste unitário `campaignNucleusUi` apontam para o novo componente.
|
||||
- `src/utilities/campaignFormFields.ts` (novo): `fieldError` / `errorProps` compartilhados por `SupporterForm` e `LeadershipForm` (cada form liga um `errorProps` local ao seu prefixo de id).
|
||||
- `src/utilities/campaignFormActionError.ts` (novo): `mapCampaignFormActionError` consolida FormDataBoundaryError → Zod → safelist → genérico. `apoiadores/novo/formActions.ts`, `apoiadores/[id]/formActions.ts` e `nucleos/[slug]/leadershipFormActions.ts` usam o mapper.
|
||||
|
||||
### Fase 3 — KPI da lista
|
||||
- `src/utilities/supporterListOverviewAggregate.ts` (novo): `computeSupporterListOverviewAggregate` faz um único `SELECT COUNT(*) FILTER (...)` via drizzle na sessão da txn Payload, espelhando `buildSupporterListWhere` + a constraint de acesso (`nucleus_id IN (...)` para coordenador, sem constraint para geral, `none` para liderança). `loadSupporterListOverviewData` passou de 3× `payload.count` para 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): `bulkInsertSupporterImport` insere contacts e supporters via `payload.db.drizzle` na sessão da txn Payload (`getPostgresTransactionDatabase`), em chunks de 500, com `ON CONFLICT DO NOTHING` no índice unique `(contact_id, nucleus_id)`. Substitui até 10k `payload.create` por poucos inserts bulk.
|
||||
- `src/collections/Contact.ts`: `enforceUniqueContactPhone` honra `context.skipContactPhoneInvariant` **fail-closed** — só respeita quando `req.transactionID` está ativo (sem txn, os locks xact-level não podem estar segurados → throw). `upsertContactByPhone` passa 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, acesso `geral`/admin) + migration `20260719_011015_add_supporter_import_batch`: staging efêmero do conjunto `ok` no servidor.
|
||||
- `src/utilities/supporterImportToken.ts` (novo): token HMAC-SHA256(`${batchId}.${actorID}.${expiresAt}`, PAYLOAD_SECRET) com `timingSafeEqual` e TTL 10 min. `previewSupporterImportText` agora retorna `{ counts, sampleRows (100), errorReportCsv, importToken }` e estageia o conjunto `ok` em `supporterImportBatch`. `confirmSupporterImportRecord` aceita `importToken` (schema trocou `rows` por `importToken`), 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`.** Em `payload.db.tables.supporter`, as colunas de relationship são `contact`, `nucleus`, `consent`, `voteIntentionConsent`, `createdBy` — não `contactId`, etc. O insert bulk inicial usou `contactId`/`createdById` e o drizzle silenciou essas chaves (gerou `default` → violação NOT NULL). Corrigido para os nomes sem sufixo. Lição para C7 e futuros inserts bulk: inspecionar `Object.keys(payload.db.tables.<slug>)` antes de montar as rows.
|
||||
- **`payload-preferences` não serve de staging genérico.** Tem campo `user` (relationship users/campaignUser) obrigatório e validado em `beforeChange`, e semântica de preferência por usuário. Tentar usá-lo para o lote de importação falhou com `ValidationError: User`. Decidido pela tabela dedicada `supporterImportBatch` (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`) abre `dbAdapter.drizzle.transaction(...)` própria; para C4 isso quebraria a atomicidade com os locks advisory. Usado `getPostgresTransactionDatabase(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.transactionID` ativo (o lock `pg_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 quando `contactID` e `nucleusID` estã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:** `ActionPlanPagination` e os forms de planos também consomem `campaignListUrl`/`CampaignListPagination`, mas o mapper de form actions de planos ficou para C7 (escopo de copy/mensagens diferente).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6/C7 registrados — escala/DRY pós-C2 e 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; 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.
|
||||
|
||||
|
|
@ -184,7 +184,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
|
|||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
|
||||
| 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) | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, 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 |
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import { createSupporter } from '@/app/(campaign)/campanha/actions/supporter'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
FormDataBoundaryError,
|
||||
nullableRelationshipFormValue,
|
||||
optionalFormText,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
import { supporterCreateSchema } from '@/lib/schemas/supporter'
|
||||
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
|
||||
import { sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
|
||||
export type SupporterFormState = {
|
||||
|
|
@ -45,25 +43,14 @@ const getSupporterFormError = (
|
|||
error: unknown,
|
||||
values?: SupporterFormValues,
|
||||
revision?: number,
|
||||
): SupporterFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] }, values, revision }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error), values, revision }
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
safeActionMessages.includes(error.message as (typeof safeActionMessages)[number])
|
||||
) {
|
||||
return { message: error.message, values, revision }
|
||||
}
|
||||
return {
|
||||
message: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.',
|
||||
): SupporterFormState =>
|
||||
mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: safeActionMessages,
|
||||
genericMessage: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.',
|
||||
values,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const createSupporterFormAction = async (
|
||||
state: SupporterFormState,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { redirect } from 'next/navigation'
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { SupporterFilters } from '@/components/campaign/SupporterFilters'
|
||||
import { SupporterList } from '@/components/campaign/SupporterList'
|
||||
import { SupporterListOverview } from '@/components/campaign/SupporterListOverview'
|
||||
import { SupporterPagination } from '@/components/campaign/SupporterPagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels'
|
||||
import {
|
||||
buildSupporterFiltersKey,
|
||||
buildSupporterListHref,
|
||||
canAccessSupporterArea,
|
||||
getSupporterScopeLabel,
|
||||
resolveSupporterListUrl,
|
||||
|
|
@ -101,7 +102,11 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
|
||||
</p>
|
||||
<SupporterPagination state={state} totalPages={result.totalPages} />
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildSupporterListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import {
|
||||
createLeadership,
|
||||
|
|
@ -14,8 +13,8 @@ import {
|
|||
nullableFormText,
|
||||
optionalFormText,
|
||||
requiredRelationshipFormValue,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
|
||||
|
||||
export type LeadershipFormState = {
|
||||
status?: 'success'
|
||||
|
|
@ -46,35 +45,26 @@ const safeActionMessages = [
|
|||
'Somente a coordenação pode gerenciar lideranças.',
|
||||
] as const
|
||||
|
||||
const resolveLeadershipBoundaryMessage = (error: FormDataBoundaryError): string =>
|
||||
error.field === 'contact'
|
||||
? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.'
|
||||
: error.field === 'id' || error.field === 'nucleus'
|
||||
? 'Não foi possível identificar o registro. Atualize a página e tente novamente.'
|
||||
: error.message
|
||||
|
||||
const getLeadershipFormError = (
|
||||
error: unknown,
|
||||
values?: LeadershipFormValues,
|
||||
revision?: number,
|
||||
): LeadershipFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
const message =
|
||||
error.field === 'contact'
|
||||
? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.'
|
||||
: error.field === 'id' || error.field === 'nucleus'
|
||||
? 'Não foi possível identificar o registro. Atualize a página e tente novamente.'
|
||||
: error.message
|
||||
return { fieldErrors: { [error.field]: [message] }, values, revision }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error), values, revision }
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
safeActionMessages.includes(error.message as (typeof safeActionMessages)[number])
|
||||
) {
|
||||
return { message: error.message, values, revision }
|
||||
}
|
||||
return {
|
||||
message: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.',
|
||||
): LeadershipFormState =>
|
||||
mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: safeActionMessages,
|
||||
genericMessage: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.',
|
||||
values,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
resolveBoundaryMessage: resolveLeadershipBoundaryMessage,
|
||||
})
|
||||
|
||||
export const createLeadershipFormAction = async (
|
||||
state: LeadershipFormState,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
|
||||
import { NucleusList } from '@/components/campaign/NucleusList'
|
||||
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
|
||||
import { NucleusPagination } from '@/components/campaign/NucleusPagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -24,6 +24,7 @@ import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPage
|
|||
import { loadNucleusListPageData } from '@/utilities/nucleusPageData'
|
||||
import {
|
||||
buildNucleusFiltersKey,
|
||||
buildNucleusListHref,
|
||||
getCampaignScopeLabel,
|
||||
resolveNucleusListUrl,
|
||||
} from '@/utilities/nucleusUi'
|
||||
|
|
@ -89,7 +90,11 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
|
|||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'}
|
||||
</p>
|
||||
<NucleusPagination state={state} totalPages={result.totalPages} />
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildNucleusListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters'
|
||||
import { ActionPlanList } from '@/components/campaign/ActionPlanList'
|
||||
import { ActionPlanPagination } from '@/components/campaign/ActionPlanPagination'
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
import type { ActionPlan } from '@/payload-types'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
|
||||
import { buildActionPlanFiltersKey, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
|
||||
import { buildActionPlanFiltersKey, buildActionPlanListHref, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
|
||||
import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
|
||||
|
||||
type ActionPlanListPageProps = {
|
||||
|
|
@ -71,7 +71,11 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
|
|||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
|
||||
</p>
|
||||
<ActionPlanPagination state={state} totalPages={result.totalPages} />
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildActionPlanListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
|||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { normalizeBrazilianPhone } from '@/utilities/phone'
|
||||
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
|
||||
import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk'
|
||||
import {
|
||||
issueSupporterImportToken,
|
||||
loadSupporterImportBatch,
|
||||
deleteSupporterImportBatch,
|
||||
storeSupporterImportBatch,
|
||||
verifySupporterImportToken,
|
||||
} from '@/utilities/supporterImportToken'
|
||||
import {
|
||||
isSupporterImportOkRow,
|
||||
type SupporterImportPreviewResult,
|
||||
|
|
@ -39,6 +47,35 @@ export type {
|
|||
|
||||
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 => {
|
||||
const header = 'linha,nome,telefone,municipio,intencao,status\n'
|
||||
const body = rows
|
||||
.filter(isPreviewErrorRow)
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${importStatusCsvValue[row.status]}`,
|
||||
)
|
||||
.join('\n')
|
||||
return header + body
|
||||
}
|
||||
|
||||
const getFreshStaffActor = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -149,6 +186,10 @@ const upsertContactByPhone = async ({
|
|||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
// Callers of upsertContactByPhone have already acquired the phone advisory
|
||||
// lock in the same transaction, so the Contact phone-invariant hook can
|
||||
// skip its redundant lock+availability check.
|
||||
context: { skipContactPhoneInvariant: true },
|
||||
req,
|
||||
})
|
||||
|
||||
|
|
@ -333,7 +374,7 @@ export const previewSupporterImportText = async (
|
|||
actor: CampaignUser,
|
||||
csvText: string,
|
||||
): Promise<SupporterImportPreviewResult> => {
|
||||
await getFreshGeneralActor(payload, actor)
|
||||
const currentActor = await getFreshGeneralActor(payload, actor)
|
||||
await requireSupporterRegistrationConsent(payload)
|
||||
await requireSupporterVoteIntentionConsent(payload)
|
||||
|
||||
|
|
@ -490,7 +531,31 @@ export const previewSupporterImportText = async (
|
|||
.length,
|
||||
}
|
||||
|
||||
return { rows, counts }
|
||||
const okRows = rows.filter(isSupporterImportOkRow).map((row) => ({
|
||||
nome: row.nome,
|
||||
telefone: row.normalizedPhone,
|
||||
municipio: row.canonicalCity,
|
||||
intencao: row.voteIntention,
|
||||
}))
|
||||
|
||||
const errorReportCsv = buildSupporterImportErrorReportCsv(rows)
|
||||
const sampleRows = rows.slice(0, SUPPORTER_IMPORT_SAMPLE_SIZE)
|
||||
|
||||
// Stage the full ok set server-side keyed by an HMAC-signed token so the wizard
|
||||
// never has to hold/re-send thousands of rows across the Server Action boundary.
|
||||
const issued = issueSupporterImportToken(currentActor.id)
|
||||
await storeSupporterImportBatch(payload, issued.batchId, {
|
||||
actorID: currentActor.id,
|
||||
expiresAt: issued.expiresAt,
|
||||
okRows,
|
||||
})
|
||||
|
||||
return {
|
||||
counts,
|
||||
sampleRows,
|
||||
errorReportCsv,
|
||||
importToken: issued.token,
|
||||
}
|
||||
}
|
||||
|
||||
export const previewSupporterImport = async (csvText: string) => {
|
||||
|
|
@ -505,6 +570,14 @@ export const confirmSupporterImportRecord = async (
|
|||
) => {
|
||||
const data = supporterImportConfirmSchema.parse(input)
|
||||
|
||||
// Verify the HMAC token and load the staged batch BEFORE opening the write
|
||||
// transaction, so an invalid/expired token is rejected without holding locks.
|
||||
const verified = verifySupporterImportToken(data.importToken, actor.id)
|
||||
const batch = await loadSupporterImportBatch(payload, verified.batchId, actor.id)
|
||||
if (batch.okRows.length === 0) {
|
||||
throw new Error('O lote de importação não contém apoiadores válidos.')
|
||||
}
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
|
|
@ -516,111 +589,31 @@ export const confirmSupporterImportRecord = async (
|
|||
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
|
||||
}
|
||||
|
||||
const phones = [...new Set(data.rows.map((row) => row.telefone))]
|
||||
const phones = [...new Set(batch.okRows.map((row) => row.telefone))]
|
||||
await acquireContactPhoneLocks(payload, req, phones)
|
||||
|
||||
const existingContacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { in: phones } },
|
||||
depth: 0,
|
||||
limit: phones.length,
|
||||
pagination: false,
|
||||
select: { phone: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const contactIdByPhone = new Map(
|
||||
existingContacts.docs.map((doc) => [doc.phone, doc.id] as const),
|
||||
)
|
||||
|
||||
for (const row of data.rows) {
|
||||
if (contactIdByPhone.has(row.telefone)) continue
|
||||
const { contactID } = await upsertContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: row.telefone,
|
||||
name: row.nome,
|
||||
city: row.municipio,
|
||||
})
|
||||
contactIdByPhone.set(row.telefone, contactID)
|
||||
}
|
||||
|
||||
const contactIDs = [...new Set(contactIdByPhone.values())]
|
||||
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) => relationshipId(doc.contact))
|
||||
.filter((id): id is number => id !== null),
|
||||
)
|
||||
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
const errors: Array<{ telefone: string; message: string }> = []
|
||||
const consentNote =
|
||||
data.consentNote ??
|
||||
'Importação CSV com atestado do operador sobre consentimento dos titulares.'
|
||||
const consentedAt = new Date().toISOString()
|
||||
|
||||
for (const row of data.rows) {
|
||||
const contactID = contactIdByPhone.get(row.telefone)
|
||||
if (!contactID) {
|
||||
skipped += 1
|
||||
errors.push({ telefone: row.telefone, message: 'Contato não encontrado após upsert.' })
|
||||
continue
|
||||
}
|
||||
const result = await bulkInsertSupporterImport({
|
||||
payload,
|
||||
req,
|
||||
actorID: currentActor.id,
|
||||
rows: batch.okRows.map((row) => ({
|
||||
telefone: row.telefone,
|
||||
nome: row.nome,
|
||||
municipio: row.municipio,
|
||||
intencao: row.intencao,
|
||||
})),
|
||||
registrationConsent,
|
||||
voteIntentionConsent,
|
||||
consentNote,
|
||||
})
|
||||
|
||||
if (supporterContactIDs.has(contactID)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'supporter',
|
||||
data: {
|
||||
contact: contactID,
|
||||
source: 'import_csv',
|
||||
voteIntention: row.intencao,
|
||||
consent: registrationConsent.id,
|
||||
consentContentHash: registrationConsent.contentHash,
|
||||
consentedAt,
|
||||
consentNote,
|
||||
...(row.intencao
|
||||
? {
|
||||
voteIntentionConsent: voteIntentionConsent.id,
|
||||
voteIntentionConsentContentHash: voteIntentionConsent.contentHash,
|
||||
voteIntentionConsentedAt: consentedAt,
|
||||
}
|
||||
: {}),
|
||||
createdBy: currentActor.id,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
supporterContactIDs.add(contactID)
|
||||
created += 1
|
||||
} catch (error) {
|
||||
skipped += 1
|
||||
errors.push({
|
||||
telefone: row.telefone,
|
||||
message: error instanceof Error ? error.message : 'Erro ao importar linha.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { created, skipped, errors }
|
||||
// Single-use token: drop the staged batch once the import commits.
|
||||
await deleteSupporterImportBatch(payload, verified.batchId, req)
|
||||
return result
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a transação de importação de apoiadores.' },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,20 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
|
|||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
// Opt-out for callers that have already acquired the phone advisory locks in the
|
||||
// SAME Payload transaction (e.g. bulk import). Fail closed: the flag is only
|
||||
// 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) {
|
||||
throw new APIError(
|
||||
'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.',
|
||||
500,
|
||||
)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const phone = String(data.phone ?? originalDoc?.phone ?? '')
|
||||
const oldPhone =
|
||||
operation === 'update' && typeof originalDoc?.phone === 'string'
|
||||
|
|
|
|||
72
src/collections/SupporterImportBatch.ts
Normal file
72
src/collections/SupporterImportBatch.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { Access, CollectionConfig } from 'payload'
|
||||
|
||||
import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* Short-lived server-side staging for the supporter CSV import wizard (roadmap
|
||||
* C6, Phase 5). The preview stores the full `ok` row set here keyed by an
|
||||
* HMAC-signed token; the confirm step redeems the token, consumes the batch,
|
||||
* and bulk-inserts. This keeps thousands of rows from crossing the Server
|
||||
* 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).
|
||||
*/
|
||||
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: {
|
||||
singular: 'Lote de importação',
|
||||
plural: 'Lotes de importação',
|
||||
},
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
hidden: () => true,
|
||||
useAsTitle: 'batchId',
|
||||
defaultColumns: ['batchId', 'actor', 'expiresAt'],
|
||||
},
|
||||
access: {
|
||||
create: canAccessImportBatch,
|
||||
read: canAccessImportBatch,
|
||||
update: canAccessImportBatch,
|
||||
delete: canAccessImportBatch,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'batchId',
|
||||
type: 'text',
|
||||
label: 'ID do lote',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'actor',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Operador',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'expiresAt',
|
||||
type: 'date',
|
||||
label: 'Expira em',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'rows',
|
||||
type: 'json',
|
||||
label: 'Linhas válidas',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/Pagination'
|
||||
import { buildActionPlanListHref, type ActionPlanListState } from '@/utilities/actionPlanUi'
|
||||
|
||||
export const ActionPlanPagination = ({
|
||||
state,
|
||||
totalPages,
|
||||
}: {
|
||||
state: ActionPlanListState
|
||||
totalPages: number
|
||||
}) => {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={buildActionPlanListHref(state, Math.max(1, state.page - 1))}
|
||||
aria-disabled={state.page <= 1}
|
||||
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((page, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && page - previousPage > 1
|
||||
|
||||
return (
|
||||
<PaginationItem key={page} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
href={buildActionPlanListHref(state, page)}
|
||||
isActive={page === state.page}
|
||||
aria-label={`Ir para a página ${page}`}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={buildActionPlanListHref(state, Math.min(totalPages, state.page + 1))}
|
||||
aria-disabled={state.page >= totalPages}
|
||||
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
68
src/components/campaign/CampaignListPagination.tsx
Normal file
68
src/components/campaign/CampaignListPagination.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/Pagination'
|
||||
|
||||
export const getPaginationPages = (page: number, totalPages: number): number[] => {
|
||||
const pages = new Set([1, totalPages, page - 1, page, page + 1])
|
||||
return [...pages]
|
||||
.filter((candidate) => candidate >= 1 && candidate <= totalPages)
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export const CampaignListPagination = ({
|
||||
page,
|
||||
totalPages,
|
||||
hrefForPage,
|
||||
}: {
|
||||
page: number
|
||||
totalPages: number
|
||||
hrefForPage: (page: number) => string
|
||||
}) => {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const visiblePages = getPaginationPages(page, totalPages)
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={hrefForPage(Math.max(1, page - 1))}
|
||||
aria-disabled={page <= 1}
|
||||
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((pageNumber, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNumber} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
href={hrefForPage(pageNumber)}
|
||||
isActive={pageNumber === page}
|
||||
aria-label={`Ir para a página ${pageNumber}`}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={hrefForPage(Math.min(totalPages, page + 1))}
|
||||
aria-disabled={page >= totalPages}
|
||||
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi'
|
||||
import type { LeadershipEditViewModel } from '@/utilities/leadershipViewModels'
|
||||
import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
|
||||
export type LeadershipFormAction = (
|
||||
|
|
@ -49,19 +50,10 @@ const supportStatusOptions = [
|
|||
{ value: 'negativo', label: 'Negativo' },
|
||||
] as const
|
||||
|
||||
const fieldError = (
|
||||
const errorProps = (
|
||||
fieldErrors: Record<string, string[]> | undefined,
|
||||
field: string,
|
||||
): string | undefined => fieldErrors?.[field]?.[0]
|
||||
|
||||
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
|
||||
const error = fieldError(fieldErrors, field)
|
||||
return {
|
||||
error,
|
||||
invalid: Boolean(error),
|
||||
describedBy: error ? `leadership-${field}-error` : undefined,
|
||||
}
|
||||
}
|
||||
) => buildErrorProps(fieldErrors, field, 'leadership')
|
||||
|
||||
export const LeadershipFormFeedback = ({
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/Pagination'
|
||||
import { buildNucleusListHref, type NucleusListState } from '@/utilities/nucleusUi'
|
||||
|
||||
export const getNucleusPaginationPages = (page: number, totalPages: number): number[] => {
|
||||
const pages = new Set([1, totalPages, page - 1, page, page + 1])
|
||||
return [...pages]
|
||||
.filter((candidate) => candidate >= 1 && candidate <= totalPages)
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export const NucleusPagination = ({
|
||||
state,
|
||||
totalPages,
|
||||
}: {
|
||||
state: NucleusListState
|
||||
totalPages: number
|
||||
}) => {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={buildNucleusListHref(state, Math.max(1, state.page - 1))}
|
||||
aria-disabled={state.page <= 1}
|
||||
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((page, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && page - previousPage > 1
|
||||
|
||||
return (
|
||||
<PaginationItem key={page} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
href={buildNucleusListHref(state, page)}
|
||||
isActive={page === state.page}
|
||||
aria-label={`Ir para a página ${page}`}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={buildNucleusListHref(state, Math.min(totalPages, state.page + 1))}
|
||||
aria-disabled={state.page >= totalPages}
|
||||
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,16 @@ import { useActionState } from 'react'
|
|||
import type { SupporterRemoveFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { removeSupporterDataFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/AlertDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
||||
|
|
@ -21,32 +31,39 @@ export const RemoveSupporterDataButton = ({ supporterId }: { supporterId: number
|
|||
Exercício do direito de eliminação (LGPD art. 18). Remove o cadastro de apoiador e anonimiza
|
||||
o contato quando não houver outros vínculos.
|
||||
</p>
|
||||
{state.message ? (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<form action={formAction} className="mt-4">
|
||||
<input type="hidden" name="id" value={supporterId} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="min-h-11"
|
||||
disabled={pending}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Confirma a remoção dos dados deste apoiador? Esta ação não pode ser desfeita.',
|
||||
)
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Removendo…' : 'Remover dados'}
|
||||
</Button>
|
||||
</form>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" variant="destructive" className="mt-4 min-h-11">
|
||||
Remover dados
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remover os dados deste apoiador?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. O cadastro de apoiador será removido e o contato
|
||||
anonimizado quando não houver outros vínculos.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{state.message ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="id" value={supporterId} />
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<Button type="submit" variant="destructive" className="min-h-11" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Removendo…' : 'Confirmar remoção'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { toast } from 'sonner'
|
|||
|
||||
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
|
|
@ -22,28 +23,21 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
|
||||
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
|
||||
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
|
||||
|
||||
export type SupporterFormAction = (
|
||||
state: SupporterFormState,
|
||||
formData: FormData,
|
||||
) => Promise<SupporterFormState>
|
||||
|
||||
const fieldError = (
|
||||
const errorProps = (
|
||||
fieldErrors: Record<string, string[]> | undefined,
|
||||
field: string,
|
||||
): string | undefined => fieldErrors?.[field]?.[0]
|
||||
|
||||
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
|
||||
const error = fieldError(fieldErrors, field)
|
||||
return {
|
||||
error,
|
||||
invalid: Boolean(error),
|
||||
describedBy: error ? `supporter-${field}-error` : undefined,
|
||||
}
|
||||
}
|
||||
) => buildErrorProps(fieldErrors, field, 'supporter')
|
||||
|
||||
const ConsentBlock = ({
|
||||
id,
|
||||
|
|
@ -115,6 +109,7 @@ export const SupporterForm = ({
|
|||
const [voteIntention, setVoteIntention] = useState<SupporterVoteIntention | ''>(
|
||||
(state.values?.voteIntention as SupporterVoteIntention | undefined) ?? '',
|
||||
)
|
||||
const [city, setCity] = useState<string>((state.values?.city as string | undefined) ?? '')
|
||||
const values = state.values
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -204,13 +199,13 @@ export const SupporterForm = ({
|
|||
|
||||
<Field data-invalid={cityField.invalid}>
|
||||
<FieldLabel htmlFor="supporter-city">Município</FieldLabel>
|
||||
<Input
|
||||
<input type="hidden" name="city" value={city} />
|
||||
<StrictCombobox
|
||||
id="supporter-city"
|
||||
name="city"
|
||||
defaultValue={values?.city}
|
||||
aria-invalid={cityField.invalid}
|
||||
aria-describedby={cityField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
options={municipalityComboboxOptions()}
|
||||
value={city}
|
||||
onValueChange={setCity}
|
||||
error={cityField.error}
|
||||
/>
|
||||
{cityField.error ? (
|
||||
<FieldError id="supporter-city-error">{cityField.error}</FieldError>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
isSupporterImportOkRow,
|
||||
type SupporterImportPreviewResult,
|
||||
type SupporterImportPreviewRow,
|
||||
} from '@/utilities/supporterImport'
|
||||
|
|
@ -49,16 +48,8 @@ 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 = (rows: SupporterImportPreviewRow[]) => {
|
||||
const errorRows = rows.filter(isPreviewErrorRow)
|
||||
const header = 'linha,nome,telefone,municipio,intencao,status\n'
|
||||
const body = errorRows
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.line},"${row.nome.replace(/"/g, '""')}","${row.telefone}","${row.municipio}","${row.intencao}",${row.status}`,
|
||||
)
|
||||
.join('\n')
|
||||
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8' })
|
||||
const downloadErrorReport = (errorReportCsv: string) => {
|
||||
const blob = new Blob([errorReportCsv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
|
|
@ -99,24 +90,19 @@ export const SupporterImportWizard = () => {
|
|||
}
|
||||
|
||||
const visibleRows =
|
||||
preview?.rows.filter((row) => (previewFilter === 'errors' ? isPreviewErrorRow(row) : true)) ??
|
||||
[]
|
||||
preview?.sampleRows.filter((row) =>
|
||||
previewFilter === 'errors' ? isPreviewErrorRow(row) : true,
|
||||
) ?? []
|
||||
|
||||
const confirmImport = () => {
|
||||
if (!preview) return
|
||||
setError(undefined)
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const rows = preview.rows.filter(isSupporterImportOkRow).map((row) => ({
|
||||
nome: row.nome,
|
||||
telefone: row.normalizedPhone,
|
||||
municipio: row.canonicalCity,
|
||||
intencao: row.voteIntention,
|
||||
}))
|
||||
const result = await confirmSupporterImport({
|
||||
operatorAttested: true,
|
||||
consentNote: consentNote.trim() || undefined,
|
||||
rows,
|
||||
importToken: preview.importToken,
|
||||
})
|
||||
setConfirmResult({ created: result.created, skipped: result.skipped })
|
||||
setStep(2)
|
||||
|
|
@ -210,7 +196,7 @@ export const SupporterImportWizard = () => {
|
|||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => downloadErrorReport(preview.rows)}
|
||||
onClick={() => preview && downloadErrorReport(preview.errorReportCsv)}
|
||||
>
|
||||
Baixar relatório de erros
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/Pagination'
|
||||
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
|
||||
import { buildSupporterListHref, type SupporterListState } from '@/utilities/supporterUi'
|
||||
|
||||
export const SupporterPagination = ({
|
||||
state,
|
||||
totalPages,
|
||||
}: {
|
||||
state: SupporterListState
|
||||
totalPages: number
|
||||
}) => {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={buildSupporterListHref(state, Math.max(1, state.page - 1))}
|
||||
aria-disabled={state.page <= 1}
|
||||
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((page, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && page - previousPage > 1
|
||||
|
||||
return (
|
||||
<PaginationItem key={page} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
href={buildSupporterListHref(state, page)}
|
||||
isActive={page === state.page}
|
||||
aria-label={`Ir para a página ${page}`}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={buildSupporterListHref(state, Math.min(totalPages, state.page + 1))}
|
||||
aria-disabled={state.page >= totalPages}
|
||||
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
|
@ -79,17 +79,7 @@ export const supporterImportConfirmSchema = z.object({
|
|||
error: 'É necessário atestar o consentimento dos apoiadores importados.',
|
||||
}),
|
||||
consentNote: trimmedNullableText(2000),
|
||||
rows: z
|
||||
.array(
|
||||
z.object({
|
||||
nome: z.string().trim().min(2).max(120),
|
||||
telefone: brazilianMobile,
|
||||
municipio: optionalBahiaCity,
|
||||
intencao: z.enum(supporterVoteIntentions).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(5000),
|
||||
importToken: z.string().min(1, { error: 'Token de importação ausente.' }),
|
||||
})
|
||||
|
||||
export const supporterRemoveSchema = z.object({
|
||||
|
|
|
|||
9157
src/migrations/20260719_011015_add_supporter_import_batch.json
Normal file
9157
src/migrations/20260719_011015_add_supporter_import_batch.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20260719_011015_add_supporter_import_batch.ts
Normal file
77
src/migrations/20260719_011015_add_supporter_import_batch.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* Adds the `supporterImportBatch` staging collection (roadmap C6, Phase 5):
|
||||
* short-lived server-side storage for the supporter CSV import wizard so the
|
||||
* full ok-row set never crosses the Server Action boundary twice.
|
||||
*
|
||||
* Hand-trimmed from the Payload-generated diff: the generator also tried to
|
||||
* recreate the `supporter` table, its enums, FKs, indexes, and the
|
||||
* `payload_locked_documents_rels.supporter_id` column — all of which already
|
||||
* exist via `20260718_222656_add_supporter`. Those drift statements were
|
||||
* removed so this migration is idempotent against databases that already ran
|
||||
* the supporter migration.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "supporter_import_batch" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"batch_id" varchar NOT NULL,
|
||||
"actor_id" integer NOT NULL,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL,
|
||||
"rows" jsonb NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "supporter_import_batch_id" integer;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_import_batch_actor_id_campaign_user_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "supporter_import_batch"
|
||||
ADD CONSTRAINT "supporter_import_batch_actor_id_campaign_user_id_fk"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "public"."campaign_user"("id")
|
||||
ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "supporter_import_batch_batch_id_idx"
|
||||
ON "supporter_import_batch" USING btree ("batch_id");
|
||||
CREATE INDEX IF NOT EXISTS "supporter_import_batch_actor_idx"
|
||||
ON "supporter_import_batch" USING btree ("actor_id");
|
||||
CREATE INDEX IF NOT EXISTS "supporter_import_batch_expires_at_idx"
|
||||
ON "supporter_import_batch" USING btree ("expires_at");
|
||||
CREATE INDEX IF NOT EXISTS "supporter_import_batch_updated_at_idx"
|
||||
ON "supporter_import_batch" USING btree ("updated_at");
|
||||
CREATE INDEX IF NOT EXISTS "supporter_import_batch_created_at_idx"
|
||||
ON "supporter_import_batch" USING btree ("created_at");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'payload_locked_documents_rels_supporter_import_batch_fk'
|
||||
) THEN
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
ADD CONSTRAINT "payload_locked_documents_rels_supporter_import_batch_fk"
|
||||
FOREIGN KEY ("supporter_import_batch_id") REFERENCES "public"."supporter_import_batch"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_supporter_import_batch_id_idx"
|
||||
ON "payload_locked_documents_rels" USING btree ("supporter_import_batch_id");
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_supporter_import_batch_fk";
|
||||
DROP INDEX IF EXISTS "payload_locked_documents_rels_supporter_import_batch_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "supporter_import_batch_id";
|
||||
|
||||
DROP TABLE IF EXISTS "supporter_import_batch" CASCADE;
|
||||
`)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import * as migration_20260718_190559_territorio_multi_municipio_bairro from './
|
|||
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
|
||||
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
|
||||
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
|
||||
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -60,4 +61,9 @@ export const migrations = [
|
|||
down: migration_20260718_222832_add_action_plan.down,
|
||||
name: '20260718_222832_add_action_plan',
|
||||
},
|
||||
{
|
||||
up: migration_20260719_011015_add_supporter_import_batch.up,
|
||||
down: migration_20260719_011015_add_supporter_import_batch.down,
|
||||
name: '20260719_011015_add_supporter_import_batch'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export interface Config {
|
|||
electoralNucleus: ElectoralNucleus;
|
||||
leadership: Leadership;
|
||||
supporter: Supporter;
|
||||
supporterImportBatch: SupporterImportBatch;
|
||||
nucleusUpdate: NucleusUpdate;
|
||||
actionPlan: ActionPlan;
|
||||
electionTally: ElectionTally;
|
||||
|
|
@ -101,6 +102,7 @@ export interface Config {
|
|||
electoralNucleus: ElectoralNucleusSelect<false> | ElectoralNucleusSelect<true>;
|
||||
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
|
||||
supporter: SupporterSelect<false> | SupporterSelect<true>;
|
||||
supporterImportBatch: SupporterImportBatchSelect<false> | SupporterImportBatchSelect<true>;
|
||||
nucleusUpdate: NucleusUpdateSelect<false> | NucleusUpdateSelect<true>;
|
||||
actionPlan: ActionPlanSelect<false> | ActionPlanSelect<true>;
|
||||
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
|
||||
|
|
@ -475,6 +477,27 @@ export interface Supporter {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supporterImportBatch".
|
||||
*/
|
||||
export interface SupporterImportBatch {
|
||||
id: number;
|
||||
batchId: string;
|
||||
actor: number | CampaignUser;
|
||||
expiresAt: string;
|
||||
rows:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "nucleusUpdate".
|
||||
|
|
@ -909,6 +932,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'supporter';
|
||||
value: number | Supporter;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'supporterImportBatch';
|
||||
value: number | SupporterImportBatch;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'nucleusUpdate';
|
||||
value: number | NucleusUpdate;
|
||||
|
|
@ -1186,6 +1213,18 @@ export interface SupporterSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supporterImportBatch_select".
|
||||
*/
|
||||
export interface SupporterImportBatchSelect<T extends boolean = true> {
|
||||
batchId?: T;
|
||||
actor?: T;
|
||||
expiresAt?: T;
|
||||
rows?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "nucleusUpdate_select".
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { Post } from './collections/Post'
|
|||
import { Signature } from './collections/Signature'
|
||||
import { Subscription } from './collections/Subscription'
|
||||
import { Supporter } from './collections/Supporter'
|
||||
import { SupporterImportBatch } from './collections/SupporterImportBatch'
|
||||
import { Tag } from './collections/Tag'
|
||||
import { Users } from './collections/Users'
|
||||
import { HomePage } from './globals/HomePage'
|
||||
|
|
@ -51,6 +52,7 @@ export default buildConfig({
|
|||
ElectoralNucleus,
|
||||
Leadership,
|
||||
Supporter,
|
||||
SupporterImportBatch,
|
||||
NucleusUpdate,
|
||||
ActionPlan,
|
||||
ElectionTally,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ import {
|
|||
territoryForCity,
|
||||
} from '@/lib/bahiaTerritories'
|
||||
import { actionPlanKinds, actionPlanStatuses } from '@/lib/schemas/actionPlan'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams as CampaignListRawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
export const actionPlanPageSize = 25
|
||||
|
|
@ -33,27 +41,12 @@ export type ActionPlanListState = {
|
|||
city?: string
|
||||
}
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
type RawSearchParams = CampaignListRawSearchParams
|
||||
|
||||
export const actionPlanListParamNames = ['tab', 'kind', 'status', 'region', 'city', 'page'] as const
|
||||
|
||||
const actionPlanListParamNameSet = new Set<string>(actionPlanListParamNames)
|
||||
|
||||
const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
const normalizedText = (value: string | undefined): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
const strictDecimalInteger = (value: string | undefined): number | undefined => {
|
||||
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) ? number : undefined
|
||||
}
|
||||
|
||||
const canonicalTerritoryBySearchValue = new Map(
|
||||
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
|
||||
)
|
||||
|
|
@ -155,31 +148,8 @@ export const buildActionPlanListSearchParams = (
|
|||
export const buildActionPlanFiltersKey = (state: ActionPlanListState): string =>
|
||||
buildActionPlanListSearchParams(state).toString()
|
||||
|
||||
export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string => {
|
||||
const params = buildActionPlanListSearchParams(state, page)
|
||||
const query = params.toString()
|
||||
return query ? `/campanha/planos?${query}` : '/campanha/planos'
|
||||
}
|
||||
|
||||
const inspectRawActionPlanListParams = (
|
||||
params: RawSearchParams,
|
||||
): { hasUnsupportedParams: boolean; query: string } => {
|
||||
const serialized = new URLSearchParams()
|
||||
let hasUnsupportedParams = false
|
||||
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
if (!actionPlanListParamNameSet.has(name)) {
|
||||
hasUnsupportedParams = true
|
||||
continue
|
||||
}
|
||||
if (value === undefined) continue
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
serialized.append(name, item)
|
||||
}
|
||||
}
|
||||
|
||||
return { hasUnsupportedParams, query: serialized.toString() }
|
||||
}
|
||||
export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string =>
|
||||
buildListHref(state, buildActionPlanListSearchParams, '/campanha/planos', page)
|
||||
|
||||
export const resolveActionPlanListUrl = (
|
||||
params: RawSearchParams,
|
||||
|
|
@ -188,22 +158,12 @@ export const resolveActionPlanListUrl = (
|
|||
state: ActionPlanListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} => {
|
||||
const parsedState = parseActionPlanListParams(params)
|
||||
const page =
|
||||
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
|
||||
? totalPages
|
||||
: parsedState.page
|
||||
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
|
||||
const canonicalParams = buildActionPlanListSearchParams(state)
|
||||
const canonicalQuery = canonicalParams.toString()
|
||||
const href = canonicalQuery ? `/campanha/planos?${canonicalQuery}` : '/campanha/planos'
|
||||
const raw = inspectRawActionPlanListParams(params)
|
||||
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
|
||||
|
||||
return {
|
||||
state,
|
||||
href,
|
||||
...(needsRedirect ? { redirectHref: href } : {}),
|
||||
}
|
||||
}
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: actionPlanListParamNameSet,
|
||||
parse: parseActionPlanListParams,
|
||||
buildSearchParams: buildActionPlanListSearchParams,
|
||||
basePath: '/campanha/planos',
|
||||
totalPages,
|
||||
})
|
||||
|
|
|
|||
40
src/utilities/campaignFormActionError.ts
Normal file
40
src/utilities/campaignFormActionError.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ZodError } from 'zod'
|
||||
|
||||
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
|
||||
|
||||
export type CampaignFormErrorState<Values> = {
|
||||
fieldErrors?: Record<string, string[]>
|
||||
message?: string
|
||||
values?: Values
|
||||
revision?: number
|
||||
}
|
||||
|
||||
export type MapCampaignFormActionErrorOptions<Values> = {
|
||||
error: unknown
|
||||
safeMessages: readonly string[]
|
||||
genericMessage: string
|
||||
values?: Values
|
||||
revision?: number
|
||||
resolveBoundaryMessage?: (error: FormDataBoundaryError) => string
|
||||
}
|
||||
|
||||
export const mapCampaignFormActionError = <Values>(
|
||||
options: MapCampaignFormActionErrorOptions<Values>,
|
||||
): CampaignFormErrorState<Values> => {
|
||||
const { error, safeMessages, genericMessage, values, revision, resolveBoundaryMessage } = options
|
||||
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
const message = resolveBoundaryMessage ? resolveBoundaryMessage(error) : error.message
|
||||
return { fieldErrors: { [error.field]: [message] }, values, revision }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error), values, revision }
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
safeMessages.includes(error.message as (typeof safeMessages)[number])
|
||||
) {
|
||||
return { message: error.message, values, revision }
|
||||
}
|
||||
return { message: genericMessage, values, revision }
|
||||
}
|
||||
21
src/utilities/campaignFormFields.ts
Normal file
21
src/utilities/campaignFormFields.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export const fieldError = (
|
||||
fieldErrors: Record<string, string[]> | undefined,
|
||||
field: string,
|
||||
): string | undefined => fieldErrors?.[field]?.[0]
|
||||
|
||||
export const errorProps = (
|
||||
fieldErrors: Record<string, string[]> | undefined,
|
||||
field: string,
|
||||
idPrefix: string,
|
||||
): {
|
||||
error: string | undefined
|
||||
invalid: boolean
|
||||
describedBy: string | undefined
|
||||
} => {
|
||||
const error = fieldError(fieldErrors, field)
|
||||
return {
|
||||
error,
|
||||
invalid: Boolean(error),
|
||||
describedBy: error ? `${idPrefix}-${field}-error` : undefined,
|
||||
}
|
||||
}
|
||||
86
src/utilities/campaignListUrl.ts
Normal file
86
src/utilities/campaignListUrl.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
export const normalizedText = (value: string | undefined): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
export const strictDecimalInteger = (value: string | undefined): number | undefined => {
|
||||
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) ? number : undefined
|
||||
}
|
||||
|
||||
export const inspectRawListParams = (
|
||||
params: RawSearchParams,
|
||||
paramNameSet: ReadonlySet<string>,
|
||||
): { hasUnsupportedParams: boolean; query: string } => {
|
||||
const serialized = new URLSearchParams()
|
||||
let hasUnsupportedParams = false
|
||||
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
if (!paramNameSet.has(name)) {
|
||||
hasUnsupportedParams = true
|
||||
continue
|
||||
}
|
||||
if (value === undefined) continue
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
serialized.append(name, item)
|
||||
}
|
||||
}
|
||||
|
||||
return { hasUnsupportedParams, query: serialized.toString() }
|
||||
}
|
||||
|
||||
export type ListStateWithPage = { page: number }
|
||||
|
||||
export type ResolveListUrlOptions<State extends ListStateWithPage> = {
|
||||
params: RawSearchParams
|
||||
paramNameSet: ReadonlySet<string>
|
||||
parse: (params: RawSearchParams) => State
|
||||
buildSearchParams: (state: State) => URLSearchParams
|
||||
basePath: string
|
||||
totalPages?: number
|
||||
}
|
||||
|
||||
export const resolveListUrl = <State extends ListStateWithPage>(
|
||||
options: ResolveListUrlOptions<State>,
|
||||
): {
|
||||
state: State
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} => {
|
||||
const { params, paramNameSet, parse, buildSearchParams, basePath, totalPages } = options
|
||||
const parsedState = parse(params)
|
||||
const page =
|
||||
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
|
||||
? totalPages
|
||||
: parsedState.page
|
||||
const state = page === parsedState.page ? parsedState : ({ ...parsedState, page } as State)
|
||||
const canonicalParams = buildSearchParams(state)
|
||||
const canonicalQuery = canonicalParams.toString()
|
||||
const href = canonicalQuery ? `${basePath}?${canonicalQuery}` : basePath
|
||||
const raw = inspectRawListParams(params, paramNameSet)
|
||||
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
|
||||
|
||||
return {
|
||||
state,
|
||||
href,
|
||||
...(needsRedirect ? { redirectHref: href } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildListHref = <State>(
|
||||
state: State,
|
||||
buildSearchParams: (state: State, page?: number) => URLSearchParams,
|
||||
basePath: string,
|
||||
page: number,
|
||||
): string => {
|
||||
const params = buildSearchParams(state, page)
|
||||
const query = params.toString()
|
||||
return query ? `${basePath}?${query}` : basePath
|
||||
}
|
||||
|
|
@ -7,6 +7,14 @@ import {
|
|||
territoryForCity,
|
||||
} from '@/lib/bahiaTerritories'
|
||||
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams as CampaignListRawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
export const nucleusPageSize = 25
|
||||
|
|
@ -63,7 +71,7 @@ export type NucleusListState = {
|
|||
estimate?: 'confirmada' | 'sem_confirmacao'
|
||||
}
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
type RawSearchParams = CampaignListRawSearchParams
|
||||
|
||||
export const nucleusListParamNames = [
|
||||
'q',
|
||||
|
|
@ -77,21 +85,6 @@ export const nucleusListParamNames = [
|
|||
|
||||
const nucleusListParamNameSet = new Set<string>(nucleusListParamNames)
|
||||
|
||||
const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
const normalizedText = (value: FormDataEntryValue | string | undefined): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
const strictDecimalInteger = (value: string | undefined): number | undefined => {
|
||||
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) ? number : undefined
|
||||
}
|
||||
|
||||
const canonicalTerritoryBySearchValue = new Map(
|
||||
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
|
||||
)
|
||||
|
|
@ -198,31 +191,8 @@ export const buildNucleusListSearchParams = (
|
|||
export const buildNucleusFiltersKey = (state: NucleusListState): string =>
|
||||
buildNucleusListSearchParams(state).toString()
|
||||
|
||||
export const buildNucleusListHref = (state: NucleusListState, page: number): string => {
|
||||
const params = buildNucleusListSearchParams(state, page)
|
||||
const query = params.toString()
|
||||
return query ? `/campanha/nucleos?${query}` : '/campanha/nucleos'
|
||||
}
|
||||
|
||||
const inspectRawNucleusListParams = (
|
||||
params: RawSearchParams,
|
||||
): { hasUnsupportedParams: boolean; query: string } => {
|
||||
const serialized = new URLSearchParams()
|
||||
let hasUnsupportedParams = false
|
||||
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
if (!nucleusListParamNameSet.has(name)) {
|
||||
hasUnsupportedParams = true
|
||||
continue
|
||||
}
|
||||
if (value === undefined) continue
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
serialized.append(name, item)
|
||||
}
|
||||
}
|
||||
|
||||
return { hasUnsupportedParams, query: serialized.toString() }
|
||||
}
|
||||
export const buildNucleusListHref = (state: NucleusListState, page: number): string =>
|
||||
buildListHref(state, buildNucleusListSearchParams, '/campanha/nucleos', page)
|
||||
|
||||
export const resolveNucleusListUrl = (
|
||||
params: RawSearchParams,
|
||||
|
|
@ -231,25 +201,15 @@ export const resolveNucleusListUrl = (
|
|||
state: NucleusListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} => {
|
||||
const parsedState = parseNucleusListParams(params)
|
||||
const page =
|
||||
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
|
||||
? totalPages
|
||||
: parsedState.page
|
||||
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
|
||||
const canonicalParams = buildNucleusListSearchParams(state)
|
||||
const canonicalQuery = canonicalParams.toString()
|
||||
const href = canonicalQuery ? `/campanha/nucleos?${canonicalQuery}` : '/campanha/nucleos'
|
||||
const raw = inspectRawNucleusListParams(params)
|
||||
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
|
||||
|
||||
return {
|
||||
state,
|
||||
href,
|
||||
...(needsRedirect ? { redirectHref: href } : {}),
|
||||
}
|
||||
}
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: nucleusListParamNameSet,
|
||||
parse: parseNucleusListParams,
|
||||
buildSearchParams: buildNucleusListSearchParams,
|
||||
basePath: '/campanha/nucleos',
|
||||
totalPages,
|
||||
})
|
||||
|
||||
export const getCampaignScopeLabel = (role: CampaignUser['role'], nucleusCount: number): string => {
|
||||
if (role === 'coordenador') {
|
||||
|
|
|
|||
|
|
@ -33,12 +33,17 @@ export type SupporterImportPreviewRow =
|
|||
})
|
||||
|
||||
export type SupporterImportPreviewResult = {
|
||||
rows: SupporterImportPreviewRow[]
|
||||
counts: {
|
||||
ok: number
|
||||
duplicate: number
|
||||
error: number
|
||||
}
|
||||
/** First N rows for the on-screen preview table (the full set stays server-side). */
|
||||
sampleRows: SupporterImportPreviewRow[]
|
||||
/** Precomputed CSV of every error row, for the "Baixar relatório de erros" button. */
|
||||
errorReportCsv: string
|
||||
/** HMAC-signed token that `confirmSupporterImport` exchanges for the staged batch. */
|
||||
importToken: string
|
||||
}
|
||||
|
||||
export const isSupporterImportOkRow = (
|
||||
|
|
|
|||
256
src/utilities/supporterImportBulk.ts
Normal file
256
src/utilities/supporterImportBulk.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import type { ConsentDescriptor } from '@/utilities/campaignConsent'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
export type SupporterImportBulkRow = {
|
||||
telefone: string
|
||||
nome: string
|
||||
municipio?: string
|
||||
intencao?: SupporterVoteIntention
|
||||
}
|
||||
|
||||
export type SupporterImportBulkResult = {
|
||||
created: number
|
||||
skipped: number
|
||||
errors: Array<{ telefone: string; message: string }>
|
||||
}
|
||||
|
||||
type DrizzleInsertResult = { rowCount?: number | null }
|
||||
|
||||
type DrizzleTx = {
|
||||
insert: (table: unknown) => {
|
||||
values: (rows: Record<string, unknown>[]) => {
|
||||
onConflictDoNothing: () => Promise<DrizzleInsertResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DrizzleTables = Record<string, Record<string, unknown>>
|
||||
|
||||
type PayloadDb = {
|
||||
drizzle: unknown
|
||||
tables: DrizzleTables
|
||||
}
|
||||
|
||||
const CONTACT_TABLE = 'contact'
|
||||
const SUPPORTER_TABLE = 'supporter'
|
||||
|
||||
/** ~16 columns × 500 ≈ 8000 bind params — well under PG 65535. */
|
||||
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[])
|
||||
return out
|
||||
}
|
||||
|
||||
const getTables = (payload: Payload): DrizzleTables =>
|
||||
(payload.db as unknown as PayloadDb).tables
|
||||
|
||||
const requireTable = (tables: DrizzleTables, name: string) => {
|
||||
const table = tables[name]
|
||||
if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`)
|
||||
return table
|
||||
}
|
||||
|
||||
const buildContactRows = (
|
||||
rows: SupporterImportBulkRow[],
|
||||
now: string,
|
||||
): Array<Record<string, unknown>> =>
|
||||
rows.map((row) => ({
|
||||
name: row.nome,
|
||||
phone: row.telefone,
|
||||
email: null,
|
||||
state: 'BA',
|
||||
city: row.municipio ?? null,
|
||||
postalCode: null,
|
||||
gender: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
const buildSupporterRows = (
|
||||
entries: Array<{
|
||||
row: SupporterImportBulkRow
|
||||
contactID: number
|
||||
}>,
|
||||
args: {
|
||||
actorID: number
|
||||
registrationConsent: ConsentDescriptor
|
||||
voteIntentionConsent: ConsentDescriptor
|
||||
consentNote: string
|
||||
now: string
|
||||
},
|
||||
): Array<Record<string, unknown>> =>
|
||||
entries.map(({ row, contactID }) => ({
|
||||
// Drizzle column names for Payload relationship fields omit the `Id` suffix.
|
||||
contact: contactID,
|
||||
nucleus: null,
|
||||
voteIntention: row.intencao ?? null,
|
||||
consent: args.registrationConsent.id,
|
||||
consentContentHash: args.registrationConsent.contentHash,
|
||||
consentedAt: args.now,
|
||||
voteIntentionConsent: row.intencao ? args.voteIntentionConsent.id : null,
|
||||
voteIntentionConsentContentHash: row.intencao
|
||||
? args.voteIntentionConsent.contentHash
|
||||
: null,
|
||||
voteIntentionConsentedAt: row.intencao ? args.now : null,
|
||||
source: 'import_csv',
|
||||
consentNote: args.consentNote,
|
||||
notes: null,
|
||||
createdBy: args.actorID,
|
||||
createdAt: args.now,
|
||||
updatedAt: args.now,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Bulk-insert contacts and nucleus-less supporters for a CSV import inside the
|
||||
* caller's Payload transaction. Phone advisory locks MUST already be held for
|
||||
* every phone in `rows` (the caller acquires them before invoking this helper).
|
||||
*
|
||||
* Contacts are inserted via drizzle (bypassing the Contact phone-invariant hook,
|
||||
* which is safe because the locks guarantee uniqueness within the txn). The
|
||||
* supporter insert uses `ON CONFLICT DO NOTHING` on the unique
|
||||
* `(contact_id, nucleus_id)` index as a last-resort guard against races.
|
||||
*/
|
||||
export const bulkInsertSupporterImport = async (args: {
|
||||
payload: Payload
|
||||
req: PayloadTransactionRequest
|
||||
actorID: number
|
||||
rows: SupporterImportBulkRow[]
|
||||
registrationConsent: ConsentDescriptor
|
||||
voteIntentionConsent: ConsentDescriptor
|
||||
consentNote: string
|
||||
}): Promise<SupporterImportBulkResult> => {
|
||||
const { payload, req, actorID, rows } = args
|
||||
if (rows.length === 0) {
|
||||
return { created: 0, skipped: 0, errors: [] }
|
||||
}
|
||||
|
||||
const tables = getTables(payload)
|
||||
const contactTable = requireTable(tables, CONTACT_TABLE)
|
||||
const supporterTable = requireTable(tables, SUPPORTER_TABLE)
|
||||
const database = await getPostgresTransactionDatabase(payload, req)
|
||||
const tx = database as unknown as DrizzleTx
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const phones = [...new Set(rows.map((row) => row.telefone))]
|
||||
|
||||
const existingContacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { in: phones } },
|
||||
depth: 0,
|
||||
limit: phones.length,
|
||||
pagination: false,
|
||||
select: { phone: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const contactIdByPhone = new Map(
|
||||
existingContacts.docs.map((doc) => [doc.phone, doc.id] as const),
|
||||
)
|
||||
|
||||
// New contacts to create, deduped by phone (preview already dedupes, but this
|
||||
// is defensive against a stale confirm payload).
|
||||
const newContactByPhone = new Map<string, SupporterImportBulkRow>()
|
||||
for (const row of rows) {
|
||||
if (contactIdByPhone.has(row.telefone)) continue
|
||||
if (newContactByPhone.has(row.telefone)) continue
|
||||
newContactByPhone.set(row.telefone, row)
|
||||
}
|
||||
|
||||
if (newContactByPhone.size > 0) {
|
||||
const newRows = [...newContactByPhone.values()]
|
||||
for (const batch of chunk(newRows, INSERT_CHUNK_SIZE)) {
|
||||
await tx.insert(contactTable).values(buildContactRows(batch, now))
|
||||
}
|
||||
|
||||
const newPhones = [...newContactByPhone.keys()]
|
||||
const createdContacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { in: newPhones } },
|
||||
depth: 0,
|
||||
limit: newPhones.length,
|
||||
pagination: false,
|
||||
select: { phone: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
for (const doc of createdContacts.docs) {
|
||||
contactIdByPhone.set(doc.phone, doc.id)
|
||||
}
|
||||
}
|
||||
|
||||
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 supporterContactIDs = new Set(
|
||||
(existingSupporters?.docs ?? [])
|
||||
.map((doc) =>
|
||||
typeof doc.contact === 'number'
|
||||
? doc.contact
|
||||
: typeof doc.contact === 'object' && doc.contact !== null
|
||||
? doc.contact.id
|
||||
: null,
|
||||
)
|
||||
.filter((id): id is number => id !== null),
|
||||
)
|
||||
|
||||
const candidateEntries: Array<{ row: SupporterImportBulkRow; contactID: number }> = []
|
||||
const errors: Array<{ telefone: string; message: string }> = []
|
||||
let skipped = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const contactID = contactIdByPhone.get(row.telefone)
|
||||
if (!contactID) {
|
||||
skipped += 1
|
||||
errors.push({ telefone: row.telefone, message: 'Contato não encontrado após upsert.' })
|
||||
continue
|
||||
}
|
||||
if (supporterContactIDs.has(contactID)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
candidateEntries.push({ row, contactID })
|
||||
// Reserve against duplicate phones within the same payload.
|
||||
supporterContactIDs.add(contactID)
|
||||
}
|
||||
|
||||
let created = 0
|
||||
for (const batch of chunk(candidateEntries, INSERT_CHUNK_SIZE)) {
|
||||
if (batch.length === 0) continue
|
||||
const result = await tx
|
||||
.insert(supporterTable)
|
||||
.values(
|
||||
buildSupporterRows(batch, {
|
||||
actorID,
|
||||
registrationConsent: args.registrationConsent,
|
||||
voteIntentionConsent: args.voteIntentionConsent,
|
||||
consentNote: args.consentNote,
|
||||
now,
|
||||
}),
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
created += Number(result.rowCount ?? batch.length)
|
||||
}
|
||||
|
||||
return { created, skipped, errors }
|
||||
}
|
||||
165
src/utilities/supporterImportToken.ts
Normal file
165
src/utilities/supporterImportToken.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import 'server-only'
|
||||
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
|
||||
export type SupporterImportOkRow = {
|
||||
nome: string
|
||||
telefone: string
|
||||
municipio?: string
|
||||
intencao?: SupporterVoteIntention
|
||||
}
|
||||
|
||||
export type SupporterImportBatch = {
|
||||
actorID: number
|
||||
expiresAt: number
|
||||
okRows: SupporterImportOkRow[]
|
||||
}
|
||||
|
||||
const BATCH_COLLECTION = 'supporterImportBatch'
|
||||
/** Short-lived server-side staging; the wizard must confirm within this window. */
|
||||
const IMPORT_TOKEN_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export const storeSupporterImportBatch = async (
|
||||
payload: Payload,
|
||||
batchId: string,
|
||||
batch: SupporterImportBatch,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
await payload.create({
|
||||
collection: BATCH_COLLECTION,
|
||||
data: {
|
||||
batchId,
|
||||
actor: batch.actorID,
|
||||
expiresAt: new Date(batch.expiresAt).toISOString(),
|
||||
rows: batch.okRows,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
export const loadSupporterImportBatch = async (
|
||||
payload: Payload,
|
||||
batchId: string,
|
||||
expectedActorID: number,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<SupporterImportBatch> => {
|
||||
const result = await payload.find({
|
||||
collection: BATCH_COLLECTION,
|
||||
where: { batchId: { equals: batchId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const doc = result.docs[0] as
|
||||
| { id: number; actor: unknown; 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) {
|
||||
throw new Error('Lote de importação inválido.')
|
||||
}
|
||||
|
||||
const expiresAtMs = doc.expiresAt ? Date.parse(doc.expiresAt) : NaN
|
||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
||||
throw new Error('Lote de importação expirado.')
|
||||
}
|
||||
if (!Array.isArray(doc.rows)) {
|
||||
throw new Error('Lote de importação inválido.')
|
||||
}
|
||||
|
||||
return {
|
||||
actorID: expectedActorID,
|
||||
expiresAt: expiresAtMs,
|
||||
okRows: doc.rows as SupporterImportOkRow[],
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteSupporterImportBatch = async (
|
||||
payload: Payload,
|
||||
batchId: string,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
const result = await payload.find({
|
||||
collection: BATCH_COLLECTION,
|
||||
where: { batchId: { equals: batchId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const doc = result.docs[0] as { id: number } | undefined
|
||||
if (!doc) return
|
||||
await payload.delete({
|
||||
collection: BATCH_COLLECTION,
|
||||
id: doc.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
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.')
|
||||
return secret
|
||||
}
|
||||
|
||||
const signature = (batchId: string, actorID: number, expiresAt: number): Buffer =>
|
||||
createHmac('sha256', getSecret()).update(`${batchId}.${actorID}.${expiresAt}`).digest()
|
||||
|
||||
export type SignedImportToken = {
|
||||
token: string
|
||||
batchId: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export const issueSupporterImportToken = (actorID: number): SignedImportToken => {
|
||||
const batchId = randomUUID()
|
||||
const expiresAt = Date.now() + IMPORT_TOKEN_TTL_MS
|
||||
const sig = base64url(signature(batchId, actorID, expiresAt))
|
||||
return { token: `${batchId}.${expiresAt}.${sig}`, batchId, expiresAt }
|
||||
}
|
||||
|
||||
export const verifySupporterImportToken = (
|
||||
token: string,
|
||||
actorID: number,
|
||||
): { batchId: string; expiresAt: number } => {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) throw new Error('Token de importação inválido.')
|
||||
const [batchId, expiresAtText, sig] = parts as [string, string, string]
|
||||
const expiresAt = Number(expiresAtText)
|
||||
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) {
|
||||
throw new Error('Token de importação expirado.')
|
||||
}
|
||||
const expected = signature(batchId, actorID, expiresAt)
|
||||
const provided = base64urlToBuffer(sig)
|
||||
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
||||
throw new Error('Token de importação inválido.')
|
||||
}
|
||||
return { batchId, expiresAt }
|
||||
}
|
||||
|
||||
149
src/utilities/supporterListOverviewAggregate.ts
Normal file
149
src/utilities/supporterListOverviewAggregate.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import 'server-only'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { isCampaignGeneral } from '@/utilities/campaignAccess'
|
||||
import { normalizeBrazilianPhone } from '@/utilities/phone'
|
||||
import type { SupporterListState } from '@/utilities/supporterUi'
|
||||
import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels'
|
||||
|
||||
type AccessConstraint =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'none' }
|
||||
| { kind: 'nucleusSet'; ids: number[] }
|
||||
|
||||
type PostgresDb = {
|
||||
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
|
||||
}
|
||||
|
||||
const resultRows = (result: unknown): Array<Record<string, unknown>> => {
|
||||
if (Array.isArray(result)) return result as Array<Record<string, unknown>>
|
||||
if (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'rows' in result &&
|
||||
Array.isArray((result as { rows: unknown }).rows)
|
||||
) {
|
||||
return (result as { rows: Array<Record<string, unknown>> }).rows
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const resolveAccessConstraint = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
): Promise<AccessConstraint> => {
|
||||
if (isCampaignGeneral(user)) return { kind: 'all' }
|
||||
if (user.role !== 'coordenador') return { kind: 'none' }
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'electoralNucleus',
|
||||
where: { coordinators: { contains: user.id } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const ids = result.docs
|
||||
.map((doc) => (typeof doc.id === 'number' ? doc.id : Number(doc.id)))
|
||||
.filter((id) => Number.isSafeInteger(id))
|
||||
|
||||
return { kind: 'nucleusSet', ids }
|
||||
}
|
||||
|
||||
const buildAggregateSql = (
|
||||
state: SupporterListState,
|
||||
access: AccessConstraint,
|
||||
): ReturnType<typeof sql> => {
|
||||
const conditions: ReturnType<typeof sql>[] = []
|
||||
const needsContactJoin = Boolean(state.q || state.city)
|
||||
|
||||
if (access.kind === 'nucleusSet') {
|
||||
if (access.ids.length === 0) {
|
||||
// No accessible nuclei → no rows. Keep a trivially-false condition.
|
||||
conditions.push(sql`FALSE`)
|
||||
} else {
|
||||
conditions.push(
|
||||
sql`"supporter"."nucleus_id" IN (${sql.join(
|
||||
access.ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.voteIntention) {
|
||||
conditions.push(sql`"supporter"."vote_intention" = ${state.voteIntention}`)
|
||||
}
|
||||
|
||||
if (state.nucleus) {
|
||||
conditions.push(sql`"supporter"."nucleus_id" = ${state.nucleus}`)
|
||||
}
|
||||
|
||||
if (state.city) {
|
||||
conditions.push(sql`"contact"."city" = ${state.city}`)
|
||||
}
|
||||
|
||||
if (state.q) {
|
||||
const searchTerms: ReturnType<typeof sql>[] = [
|
||||
sql`"contact"."name" ILIKE ${`%${state.q}%`}`,
|
||||
sql`"contact"."city" ILIKE ${`%${state.q}%`}`,
|
||||
]
|
||||
const normalizedPhone = normalizeBrazilianPhone(state.q)
|
||||
if (normalizedPhone) {
|
||||
searchTerms.push(sql`"contact"."phone" = ${normalizedPhone}`)
|
||||
} else if (/\d/.test(state.q)) {
|
||||
const digits = state.q.replace(/\D/g, '')
|
||||
if (digits) searchTerms.push(sql`"contact"."phone" ILIKE ${`%${digits}%`}`)
|
||||
}
|
||||
conditions.push(sql`(${sql.join(searchTerms, sql` OR `)})`)
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? sql` WHERE ${sql.join(conditions, sql` AND `)}` : sql``
|
||||
|
||||
const fromClause = needsContactJoin
|
||||
? sql`FROM "supporter" LEFT JOIN "contact" ON "contact"."id" = "supporter"."contact_id"`
|
||||
: sql`FROM "supporter"`
|
||||
|
||||
return sql`
|
||||
SELECT
|
||||
COUNT(*) AS "total",
|
||||
COUNT(*) FILTER (WHERE "supporter"."vote_intention" IN ('certo', 'tende_a_certo')) AS "certo_and_tende",
|
||||
COUNT(*) FILTER (WHERE "supporter"."vote_intention" = 'indeciso') AS "indeciso"
|
||||
${fromClause}${whereClause}
|
||||
`
|
||||
}
|
||||
|
||||
export const computeSupporterListOverviewAggregate = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: SupporterListState,
|
||||
): Promise<SupporterListOverviewViewModel | null> => {
|
||||
const access = await resolveAccessConstraint(payload, user)
|
||||
if (access.kind === 'none') return null
|
||||
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error('O overview de apoiadores exige o adaptador PostgreSQL.')
|
||||
}
|
||||
const database = payload.db as unknown as { drizzle?: PostgresDb }
|
||||
const drizzle = database.drizzle
|
||||
if (!drizzle || typeof drizzle.execute !== 'function') {
|
||||
throw new Error('A sessão PostgreSQL do overview de apoiadores não está disponível.')
|
||||
}
|
||||
|
||||
const result = await drizzle.execute(buildAggregateSql(state, access))
|
||||
const row = resultRows(result)[0]
|
||||
if (!row) return null
|
||||
|
||||
const total = Number(row.total)
|
||||
if (!Number.isSafeInteger(total) || total === 0) return null
|
||||
|
||||
return {
|
||||
total,
|
||||
certoAndTende: Number(row.certo_and_tende ?? 0),
|
||||
indeciso: Number(row.indeciso ?? 0),
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getSupporterRegistrationConsent,
|
||||
getSupporterVoteIntentionConsent,
|
||||
} from '@/utilities/campaignConsent'
|
||||
import { computeSupporterListOverviewAggregate } from '@/utilities/supporterListOverviewAggregate'
|
||||
import {
|
||||
buildSupporterListWhere,
|
||||
resolveSupporterListUrl,
|
||||
|
|
@ -102,45 +103,11 @@ export const loadSupporterListPageData = async (
|
|||
}
|
||||
|
||||
export const loadSupporterListOverviewData = async (
|
||||
payload: Pick<Payload, 'count'>,
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: SupporterListState,
|
||||
): Promise<SupporterListOverviewViewModel | null> => {
|
||||
const where = buildSupporterListWhere(state)
|
||||
|
||||
const [total, certoAndTende, indeciso] = await Promise.all([
|
||||
payload.count({
|
||||
collection: 'supporter',
|
||||
where,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [where, { voteIntention: { in: ['certo', 'tende_a_certo'] } }],
|
||||
},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [where, { voteIntention: { equals: 'indeciso' } }],
|
||||
},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
])
|
||||
|
||||
if (total.totalDocs === 0) return null
|
||||
|
||||
return {
|
||||
total: total.totalDocs,
|
||||
certoAndTende: certoAndTende.totalDocs,
|
||||
indeciso: indeciso.totalDocs,
|
||||
}
|
||||
}
|
||||
): Promise<SupporterListOverviewViewModel | null> =>
|
||||
computeSupporterListOverviewAggregate(payload, user, state)
|
||||
|
||||
export const loadSupporterDetailPageData = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ import {
|
|||
type SupporterVoteIntention,
|
||||
} from '@/lib/schemas/supporter'
|
||||
import type { CampaignUser, Supporter } from '@/payload-types'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams as CampaignListRawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { normalizeBrazilianPhone } from '@/utilities/phone'
|
||||
|
||||
export const supporterPageSize = 25
|
||||
|
|
@ -32,27 +40,12 @@ export type SupporterListState = {
|
|||
nucleus?: number
|
||||
}
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
type RawSearchParams = CampaignListRawSearchParams
|
||||
|
||||
export const supporterListParamNames = ['q', 'voteIntention', 'city', 'nucleus', 'page'] as const
|
||||
|
||||
const supporterListParamNameSet = new Set<string>(supporterListParamNames)
|
||||
|
||||
const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
const normalizedText = (value: FormDataEntryValue | string | undefined): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
const strictDecimalInteger = (value: string | undefined): number | undefined => {
|
||||
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) ? number : undefined
|
||||
}
|
||||
|
||||
export const parseSupporterListParams = (params: RawSearchParams): SupporterListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const q = normalizedText(firstValue(params.q))
|
||||
|
|
@ -125,31 +118,8 @@ export const buildSupporterListSearchParams = (
|
|||
export const buildSupporterFiltersKey = (state: SupporterListState): string =>
|
||||
buildSupporterListSearchParams(state).toString()
|
||||
|
||||
export const buildSupporterListHref = (state: SupporterListState, page: number): string => {
|
||||
const params = buildSupporterListSearchParams(state, page)
|
||||
const query = params.toString()
|
||||
return query ? `/campanha/apoiadores?${query}` : '/campanha/apoiadores'
|
||||
}
|
||||
|
||||
const inspectRawSupporterListParams = (
|
||||
params: RawSearchParams,
|
||||
): { hasUnsupportedParams: boolean; query: string } => {
|
||||
const serialized = new URLSearchParams()
|
||||
let hasUnsupportedParams = false
|
||||
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
if (!supporterListParamNameSet.has(name)) {
|
||||
hasUnsupportedParams = true
|
||||
continue
|
||||
}
|
||||
if (value === undefined) continue
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
serialized.append(name, item)
|
||||
}
|
||||
}
|
||||
|
||||
return { hasUnsupportedParams, query: serialized.toString() }
|
||||
}
|
||||
export const buildSupporterListHref = (state: SupporterListState, page: number): string =>
|
||||
buildListHref(state, buildSupporterListSearchParams, '/campanha/apoiadores', page)
|
||||
|
||||
export const resolveSupporterListUrl = (
|
||||
params: RawSearchParams,
|
||||
|
|
@ -158,25 +128,15 @@ export const resolveSupporterListUrl = (
|
|||
state: SupporterListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} => {
|
||||
const parsedState = parseSupporterListParams(params)
|
||||
const page =
|
||||
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
|
||||
? totalPages
|
||||
: parsedState.page
|
||||
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
|
||||
const canonicalParams = buildSupporterListSearchParams(state)
|
||||
const canonicalQuery = canonicalParams.toString()
|
||||
const href = canonicalQuery ? `/campanha/apoiadores?${canonicalQuery}` : '/campanha/apoiadores'
|
||||
const raw = inspectRawSupporterListParams(params)
|
||||
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
|
||||
|
||||
return {
|
||||
state,
|
||||
href,
|
||||
...(needsRedirect ? { redirectHref: href } : {}),
|
||||
}
|
||||
}
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: supporterListParamNameSet,
|
||||
parse: parseSupporterListParams,
|
||||
buildSearchParams: buildSupporterListSearchParams,
|
||||
basePath: '/campanha/apoiadores',
|
||||
totalPages,
|
||||
})
|
||||
|
||||
export const getSupporterScopeLabel = (total: number): string =>
|
||||
`${total} ${total === 1 ? 'apoiador nos seus núcleos' : 'apoiadores nos seus núcleos'}`
|
||||
|
|
|
|||
77
tests/e2e/campaign-supporters.e2e.spec.ts
Normal file
77
tests/e2e/campaign-supporters.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* eslint-disable check-file/filename-naming-convention */
|
||||
import { test, expect } from './fixtures/campaignE2EFixtures'
|
||||
|
||||
test.describe('Campaign supporter journeys', () => {
|
||||
test.describe.configure({ mode: 'parallel' })
|
||||
|
||||
test('removes a supporter through the confirmation dialog', async ({ campaign, page }) => {
|
||||
test.setTimeout(240_000)
|
||||
const generalEmail = `${campaign.fixtures.value('removal-general')}@example.com`
|
||||
const password = campaign.fixtures.value('RemovalPassword')
|
||||
const supporterName = campaign.fixtures.value('Apoiador removível')
|
||||
|
||||
const setup = await campaign.transaction(async (payload, req) => {
|
||||
const general = await payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: campaign.fixtures.value('Coordenação Geral remoção'),
|
||||
email: generalEmail,
|
||||
password,
|
||||
role: 'geral',
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
})
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: supporterName,
|
||||
phone: campaign.fixtures.phone(),
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
})
|
||||
const supporter = await payload.create({
|
||||
collection: 'supporter',
|
||||
data: {
|
||||
contact: contact.id,
|
||||
source: 'manual',
|
||||
createdBy: general.id,
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
})
|
||||
return { general, supporter }
|
||||
})
|
||||
|
||||
await campaign.login(page, generalEmail, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/apoiadores/${setup.supporter.id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('button', { name: 'Remover dados' }).click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Remover os dados deste apoiador?' }),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Confirmar remoção' }).click()
|
||||
await page.waitForURL(`${campaign.baseURL}/campanha/apoiadores`, { timeout: 30_000 })
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await campaign.payload.find({
|
||||
collection: 'supporter',
|
||||
where: { id: { equals: setup.supporter.id } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
})
|
||||
).totalDocs,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -21,11 +21,13 @@ type OwnedCollection =
|
|||
| 'nucleusUpdate'
|
||||
| 'campaignInvite'
|
||||
| 'consent'
|
||||
| 'supporter'
|
||||
|
||||
const deletionOrder: OwnedCollection[] = [
|
||||
'campaignInvite',
|
||||
'nucleusUpdate',
|
||||
'leadership',
|
||||
'supporter',
|
||||
'electoralNucleus',
|
||||
'contact',
|
||||
'campaignUser',
|
||||
|
|
@ -174,6 +176,21 @@ class CampaignE2EOwnership {
|
|||
})
|
||||
for (const update of updates.docs) this.own('nucleusUpdate', update.id)
|
||||
}
|
||||
if (contactIDs.length || nucleusIDs.length || userIDs.length) {
|
||||
const supporters = await this.rootPayload.find({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
or: [
|
||||
...(contactIDs.length ? [{ contact: { in: contactIDs } }] : []),
|
||||
...(nucleusIDs.length ? [{ nucleus: { in: nucleusIDs } }] : []),
|
||||
...(userIDs.length ? [{ createdBy: { in: userIDs } }] : []),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
})
|
||||
for (const supporter of supporters.docs) this.own('supporter', supporter.id)
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
SUPPORTER_REGISTRATION_CONSENT_KEY,
|
||||
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
|
||||
} from '@/utilities/campaignConsent'
|
||||
import { loadSupporterListOverviewData } from '@/utilities/supporterPageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
|
|
@ -251,22 +252,23 @@ describe('campaign supporter domain', () => {
|
|||
expect(preview.counts.ok).toBe(1)
|
||||
expect(preview.counts.duplicate).toBe(1)
|
||||
expect(preview.counts.error).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const okRows = preview.rows
|
||||
.filter((row) => row.status === 'ok')
|
||||
.map((row) => ({
|
||||
nome: row.nome,
|
||||
telefone: row.normalizedPhone!,
|
||||
municipio: row.canonicalCity,
|
||||
intencao: row.voteIntention,
|
||||
}))
|
||||
expect(preview.importToken).toBeTruthy()
|
||||
expect(preview.sampleRows.length).toBeGreaterThan(0)
|
||||
|
||||
const result = await confirmSupporterImportRecord(payload, geral, {
|
||||
operatorAttested: true,
|
||||
rows: okRows,
|
||||
importToken: preview.importToken,
|
||||
})
|
||||
expect(result.created).toBe(1)
|
||||
|
||||
// Single-use token: a second confirm with the same token must fail.
|
||||
await expect(
|
||||
confirmSupporterImportRecord(payload, geral, {
|
||||
operatorAttested: true,
|
||||
importToken: preview.importToken,
|
||||
}),
|
||||
).rejects.toThrow(/não encontrado|expirado|inválido/)
|
||||
|
||||
const contact = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: phoneOk } },
|
||||
|
|
@ -288,6 +290,57 @@ describe('campaign supporter domain', () => {
|
|||
fixtures.own('supporter', created.docs[0]!.id)
|
||||
})
|
||||
|
||||
it('rejects a tampered import token and a token issued to another actor', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureSupporterConsents(fixtures)
|
||||
const geral = await fixtures.createCampaignUser('geral')
|
||||
const otherGeral = await fixtures.createCampaignUser('geral')
|
||||
const phoneOk = fixtures.phone()
|
||||
const csv = `nome,telefone,municipio,intencao\nAna Silva,${phoneOk},Salvador,certo`
|
||||
|
||||
const preview = await previewSupporterImportText(payload, geral, csv)
|
||||
expect(preview.counts.ok).toBe(1)
|
||||
|
||||
const [batchId, expiresAt, sig] = preview.importToken.split('.')
|
||||
const tampered = `${batchId}.${expiresAt}.${sig.slice(0, -2)}xx`
|
||||
await expect(
|
||||
confirmSupporterImportRecord(payload, geral, {
|
||||
operatorAttested: true,
|
||||
importToken: tampered,
|
||||
}),
|
||||
).rejects.toThrow(/inválido|expirado/)
|
||||
|
||||
// Token bound to a different actor cannot be redeemed by `geral`.
|
||||
await expect(
|
||||
confirmSupporterImportRecord(payload, otherGeral, {
|
||||
operatorAttested: true,
|
||||
importToken: preview.importToken,
|
||||
}),
|
||||
).rejects.toThrow(/não encontrado|inválido|expirado/)
|
||||
|
||||
// Cleanup: consume the still-valid token so it does not leak into other tests.
|
||||
await confirmSupporterImportRecord(payload, geral, {
|
||||
operatorAttested: true,
|
||||
importToken: preview.importToken,
|
||||
})
|
||||
const contact = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: phoneOk } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.own('contact', contact.docs[0]!.id)
|
||||
const created = await payload.find({
|
||||
collection: 'supporter',
|
||||
where: { contact: { equals: contact.docs[0]!.id } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (created.docs[0]) fixtures.own('supporter', created.docs[0].id)
|
||||
})
|
||||
|
||||
it('removes supporter and anonymizes contact when no other joins remain', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureSupporterConsents(fixtures)
|
||||
|
|
@ -315,4 +368,81 @@ describe('campaign supporter domain', () => {
|
|||
expect(contact.name).toBe('Titular removido')
|
||||
expect(contact.phone).not.toBe(phone)
|
||||
})
|
||||
|
||||
it('aggregates supporter overview KPIs in a single SQL query for geral', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
|
||||
const geral = await fixtures.createCampaignUser('geral')
|
||||
|
||||
const intentions: Array<{ voteIntention: 'certo' | 'tende_a_certo' | 'indeciso' | 'outro' | null }> = [
|
||||
{ voteIntention: 'certo' },
|
||||
{ voteIntention: 'certo' },
|
||||
{ voteIntention: 'tende_a_certo' },
|
||||
{ voteIntention: 'indeciso' },
|
||||
{ voteIntention: 'indeciso' },
|
||||
{ voteIntention: 'outro' },
|
||||
{ voteIntention: null },
|
||||
]
|
||||
for (const { voteIntention } of intentions) {
|
||||
const contact = await fixtures.createContact()
|
||||
await fixtures.createSupporter({
|
||||
contact: contact.id,
|
||||
consent: registration.id,
|
||||
consentContentHash: 'hash',
|
||||
consentedAt: new Date().toISOString(),
|
||||
createdBy: geral.id,
|
||||
...(voteIntention ? { voteIntention } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const overview = await loadSupporterListOverviewData(payload, geral, { page: 1 })
|
||||
expect(overview).not.toBeNull()
|
||||
expect(overview!.total).toBe(intentions.length)
|
||||
expect(overview!.certoAndTende).toBe(3)
|
||||
expect(overview!.indeciso).toBe(2)
|
||||
})
|
||||
|
||||
it('scopes the overview aggregate to accessible nuclei for coordenador and applies filters', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
|
||||
const coordenador = await fixtures.createCampaignUser('coordenador')
|
||||
const assigned = await fixtures.createNucleus({ coordinators: [coordenador.id] })
|
||||
const other = await fixtures.createNucleus()
|
||||
|
||||
const makeSupporter = async (
|
||||
nucleusId: number | undefined,
|
||||
voteIntention: 'certo' | 'indeciso' | null,
|
||||
city = 'Salvador',
|
||||
name = fixtures.value('Apoiador'),
|
||||
) => {
|
||||
const contact = await fixtures.createContact({ city, name })
|
||||
await fixtures.createSupporter({
|
||||
contact: contact.id,
|
||||
consent: registration.id,
|
||||
consentContentHash: 'hash',
|
||||
consentedAt: new Date().toISOString(),
|
||||
createdBy: coordenador.id,
|
||||
...(nucleusId ? { nucleus: nucleusId } : {}),
|
||||
...(voteIntention ? { voteIntention } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
await makeSupporter(assigned.id, 'certo')
|
||||
await makeSupporter(assigned.id, 'indeciso')
|
||||
await makeSupporter(assigned.id, null, 'Feira de Santana')
|
||||
await makeSupporter(other.id, 'certo') // outside scope — must not be counted
|
||||
|
||||
const overview = await loadSupporterListOverviewData(payload, coordenador, { page: 1 })
|
||||
expect(overview).not.toBeNull()
|
||||
expect(overview!.total).toBe(3)
|
||||
expect(overview!.certoAndTende).toBe(1)
|
||||
expect(overview!.indeciso).toBe(1)
|
||||
|
||||
const filtered = await loadSupporterListOverviewData(payload, coordenador, {
|
||||
page: 1,
|
||||
city: 'Feira de Santana',
|
||||
})
|
||||
expect(filtered).not.toBeNull()
|
||||
expect(filtered!.total).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
|
||||
import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
|
||||
import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog'
|
||||
import { getPaginationPages } from '@/components/campaign/CampaignListPagination'
|
||||
import { NucleusFormFields } from '@/components/campaign/NucleusForm'
|
||||
import { NucleusList } from '@/components/campaign/NucleusList'
|
||||
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
|
||||
import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields'
|
||||
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
|
||||
import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
|
||||
|
|
@ -498,9 +498,9 @@ describe('campaign nucleus UI contracts', () => {
|
|||
})
|
||||
|
||||
it('keeps pagination boundaries compact and valid', () => {
|
||||
expect(getNucleusPaginationPages(1, 10)).toEqual([1, 2, 10])
|
||||
expect(getNucleusPaginationPages(10, 10)).toEqual([1, 9, 10])
|
||||
expect(getNucleusPaginationPages(5, 10)).toEqual([1, 4, 5, 6, 10])
|
||||
expect(getPaginationPages(1, 10)).toEqual([1, 2, 10])
|
||||
expect(getPaginationPages(10, 10)).toEqual([1, 9, 10])
|
||||
expect(getPaginationPages(5, 10)).toEqual([1, 4, 5, 6, 10])
|
||||
})
|
||||
|
||||
it('returns a recoverable archive error for an invalid identifier', async () => {
|
||||
|
|
|
|||
|
|
@ -45,19 +45,25 @@ describe('supporter schemas', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it('requires operator attestation for import confirm', () => {
|
||||
it('requires operator attestation and a token for import confirm', () => {
|
||||
expect(
|
||||
supporterImportConfirmSchema.safeParse({
|
||||
rows: [{ nome: 'Ana Silva', telefone: '71988887777' }],
|
||||
importToken: 'token',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
supporterImportConfirmSchema.safeParse({
|
||||
operatorAttested: true,
|
||||
}).success,
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
supporterImportConfirmSchema.parse({
|
||||
operatorAttested: true,
|
||||
rows: [{ nome: 'Ana Silva', telefone: '71988887777', intencao: 'certo' }],
|
||||
}).rows[0]?.telefone,
|
||||
).toBe('71988887777')
|
||||
importToken: 'batch.123.sig',
|
||||
}).importToken,
|
||||
).toBe('batch.123.sig')
|
||||
})
|
||||
|
||||
it('requires vote intention consent acceptance', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue