teqo/src/utilities/supporterImportToken.ts
Francisco Ignácio Fontoura Solla 9fdbae05d7 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.
2026-07-18 22:17:31 -03:00

165 lines
4.7 KiB
TypeScript

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 }
}