Break up the god modules into deep domain modules

- campaignAccess.ts (990 lines, ~80 exports) is now a 130-line explicit
  re-export surface over 14 domain modules in src/utilities/access/
  (shared actor predicates + per-collection access); public surface
  AST-verified identical, import sites untouched
- actions/supporter.ts split: single-supporter lifecycle (407) vs the CSV
  import pipeline in supporterImport.ts (357); 'use server' re-export
  constraint honored with thin async delegates
- organization/stateDeputy clone twins deduped by POLICY:
  runStaffEntityMutation (fresh staff assert + unique-conflict translation)
  keeps each caller's concretely-typed Payload mutation
- runCampaignRedirectFormAction removes the copy-pasted try/map/redirect
  ladder from 4 create formActions (planos kept its genuinely custom flow)
- Plaza→Municipality identifier rename finished (context keys, list-label
  and leadership constants, fixture sequence, test constants)
- RefreshRouteOnSave (React component) moved out of utilities/ to components/
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-23 23:44:45 -03:00
parent 590d311364
commit f23c2582ee
34 changed files with 1858 additions and 1531 deletions

View file

@ -1,38 +1,30 @@
'use server'
import { redirect } from 'next/navigation'
import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand'
import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand'
import {
mapCampaignFormActionError,
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createDemandFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdSlug: string | null = null
try {
const rawKind = requiredFormText(formData, 'kind')
const demand = await createCampaignDemand({
title: requiredFormText(formData, 'title'),
kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind)
? (rawKind as CampaignDemandKind)
: 'outro',
description: optionalFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
})
createdSlug = demand.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria podem abrir demandas.'],
genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/demandas/${createdSlug}`)
}
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () => {
const rawKind = requiredFormText(formData, 'kind')
return createCampaignDemand({
title: requiredFormText(formData, 'title'),
kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind)
? (rawKind as CampaignDemandKind)
: 'outro',
description: optionalFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
})
},
redirectTo: (demand) => `/campanha/demandas/${demand.slug}`,
safeMessages: ['Somente a coordenação e a assessoria podem abrir demandas.'],
genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.',
})

View file

@ -1,37 +1,28 @@
'use server'
import { redirect } from 'next/navigation'
import { createStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { optionalFormText, requiredFormText } from '@/lib/formData'
import {
mapCampaignFormActionError,
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createStateDeputyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdSlug: string | null = null
try {
const stateDeputy = await createStateDeputy({
name: requiredFormText(formData, 'name'),
party: optionalFormText(formData, 'party'),
notes: optionalFormText(formData, 'notes'),
})
createdSlug = stateDeputy.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'Já existe uma dobradinha com este nome.',
'Somente a coordenação e a assessoria gerenciam dobradinhas.',
],
genericMessage:
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/dobradinhas/${createdSlug}`)
}
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () =>
createStateDeputy({
name: requiredFormText(formData, 'name'),
party: optionalFormText(formData, 'party'),
notes: optionalFormText(formData, 'notes'),
}),
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.slug}`,
safeMessages: [
'Já existe uma dobradinha com este nome.',
'Somente a coordenação e a assessoria gerenciam dobradinhas.',
],
genericMessage:
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',
})

View file

@ -1,12 +1,10 @@
'use server'
import { redirect } from 'next/navigation'
import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
import {
mapCampaignFormActionError,
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@ -20,39 +18,32 @@ const safeMessages = [
export const createLeadershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdId: number | null = null
try {
const sector = optionalFormText(formData, 'sector')
const supportStatus = optionalFormText(formData, 'supportStatus')
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () => {
const sector = optionalFormText(formData, 'sector')
const supportStatus = optionalFormText(formData, 'supportStatus')
const leadership = await createLeadership({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
? (sector as (typeof leadershipSectors)[number])
: undefined,
supportStatus: leadershipSupportStatuses.includes(
supportStatus as (typeof leadershipSupportStatuses)[number],
)
? (supportStatus as (typeof leadershipSupportStatuses)[number])
: 'a_abordar',
notes: optionalFormText(formData, 'notes'),
consentNote: optionalFormText(formData, 'consentNote'),
})
createdId = leadership.id
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage:
'Não foi possível cadastrar a liderança. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/liderancas/${createdId}`)
}
return createLeadership({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
? (sector as (typeof leadershipSectors)[number])
: undefined,
supportStatus: leadershipSupportStatuses.includes(
supportStatus as (typeof leadershipSupportStatuses)[number],
)
? (supportStatus as (typeof leadershipSupportStatuses)[number])
: 'a_abordar',
notes: optionalFormText(formData, 'notes'),
consentNote: optionalFormText(formData, 'consentNote'),
})
},
redirectTo: (leadership) => `/campanha/liderancas/${leadership.id}`,
safeMessages,
genericMessage: 'Não foi possível cadastrar a liderança. Verifique os dados e tente novamente.',
})

View file

@ -1,42 +1,34 @@
'use server'
import { redirect } from 'next/navigation'
import { createOrganization } from '@/app/(campaign)/campanha/actions/organization'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization'
import {
mapCampaignFormActionError,
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createOrganizationFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdSlug: string | null = null
try {
const rawKind = requiredFormText(formData, 'kind')
const organization = await createOrganization({
name: requiredFormText(formData, 'name'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: 'outro',
notes: optionalFormText(formData, 'notes'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
})
createdSlug = organization.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'Já existe uma organização com este nome.',
'Somente a coordenação e a assessoria gerenciam organizações.',
],
genericMessage:
'Não foi possível cadastrar a organização. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/organizacoes/${createdSlug}`)
}
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () => {
const rawKind = requiredFormText(formData, 'kind')
return createOrganization({
name: requiredFormText(formData, 'name'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: 'outro',
notes: optionalFormText(formData, 'notes'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
})
},
redirectTo: (organization) => `/campanha/organizacoes/${organization.slug}`,
safeMessages: [
'Já existe uma organização com este nome.',
'Somente a coordenação e a assessoria gerenciam organizações.',
],
genericMessage:
'Não foi possível cadastrar a organização. Verifique os dados e tente novamente.',
})

View file

@ -9,15 +9,14 @@ import {
type OrganizationUpdateInput,
} from '@/lib/schemas/organization'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const assertStaffActor = (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, 'Somente a coordenação e a assessoria gerenciam organizações.')
const isUniqueOrganizationConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /organization_(name|slug)|duplicate key/i.test(message)
const organizationPolicy: StaffEntityPolicy = {
staffMessage: 'Somente a coordenação e a assessoria gerenciam organizações.',
conflictPattern: /organization_(name|slug)|duplicate key/i,
conflictMessage: 'Já existe uma organização com este nome.',
}
export const createOrganizationRecord = async (
@ -26,22 +25,15 @@ export const createOrganizationRecord = async (
input: OrganizationCreateInput,
) => {
const data = organizationCreateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
try {
return await payload.create({
return runStaffEntityMutation(payload, actor, organizationPolicy, (currentActor) =>
payload.create({
collection: 'organization',
data: hookFilledCreateData<'organization'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
})
} catch (error) {
if (isUniqueOrganizationConflict(error)) {
throw new Error('Já existe uma organização com este nome.')
}
throw error
}
}),
)
}
export const updateOrganizationRecord = async (
@ -50,16 +42,16 @@ export const updateOrganizationRecord = async (
input: OrganizationUpdateInput,
) => {
const { id, ...data } = organizationUpdateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
return payload.update({
collection: 'organization',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
})
return runStaffEntityMutation(payload, actor, organizationPolicy, (currentActor) =>
payload.update({
collection: 'organization',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
}),
)
}
export const createOrganization = async (input: OrganizationCreateInput) => {

View file

@ -9,15 +9,14 @@ import {
type StateDeputyUpdateInput,
} from '@/lib/schemas/stateDeputy'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const assertStaffActor = (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, 'Somente a coordenação e a assessoria gerenciam dobradinhas.')
const isUniqueStateDeputyConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /state_deputy_(name|slug)|duplicate key/i.test(message)
const stateDeputyPolicy: StaffEntityPolicy = {
staffMessage: 'Somente a coordenação e a assessoria gerenciam dobradinhas.',
conflictPattern: /state_deputy_(name|slug)|duplicate key/i,
conflictMessage: 'Já existe uma dobradinha com este nome.',
}
export const createStateDeputyRecord = async (
@ -26,22 +25,15 @@ export const createStateDeputyRecord = async (
input: StateDeputyCreateInput,
) => {
const data = stateDeputyCreateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
try {
return await payload.create({
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
payload.create({
collection: 'stateDeputy',
data: hookFilledCreateData<'stateDeputy'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
})
} catch (error) {
if (isUniqueStateDeputyConflict(error)) {
throw new Error('Já existe uma dobradinha com este nome.')
}
throw error
}
}),
)
}
export const updateStateDeputyRecord = async (
@ -50,16 +42,16 @@ export const updateStateDeputyRecord = async (
input: StateDeputyUpdateInput,
) => {
const { id, ...data } = stateDeputyUpdateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
return payload.update({
collection: 'stateDeputy',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
})
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
payload.update({
collection: 'stateDeputy',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
}),
)
}
export const createStateDeputy = async (input: StateDeputyCreateInput) => {

View file

@ -1,22 +1,14 @@
'use server'
import { parse } from 'csv-parse/sync'
import type { Payload } from 'payload'
import {
resolveBahiaMunicipality,
supporterCreateSchema,
supporterImportConfirmSchema,
supporterRemoveSchema,
supporterVoteIntentionSchema,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Contact } from '@/payload-types'
import {
getCampaignActionContext,
reloadCoordinatorActor,
reloadStaffActor,
} from '@/utilities/campaignActionContext'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
requireSupporterRegistrationConsent,
requireSupporterVoteIntentionConsent,
@ -25,24 +17,15 @@ import {
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import { requireRelationshipId } from '@/utilities/relationship'
import { isUniqueSupporterConflict } from '@/utilities/supporterErrors'
import {
isPreviewErrorRow,
isSupporterImportOkRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
type SupporterImportPreviewRowBase,
} from '@/utilities/supporterImport'
import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk'
import {
deleteSupporterImportBatch,
issueSupporterImportToken,
loadSupporterImportBatch,
storeSupporterImportBatch,
verifySupporterImportToken,
} from '@/utilities/supporterImportToken'
confirmSupporterImport as confirmSupporterImportAction,
confirmSupporterImportRecord as confirmSupporterImportRecordAction,
previewSupporterImport as previewSupporterImportAction,
previewSupporterImportText as previewSupporterImportTextAction,
} from './supporterImport'
export type {
SupporterImportOkRow,
@ -51,25 +34,6 @@ export type {
SupporterImportRowStatus,
} from '@/utilities/supporterImport'
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 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)},${row.status}`,
)
.join('\n')
return header + body
}
const getFreshStaffActor = (
payload: Payload,
actor: CampaignUser,
@ -82,18 +46,6 @@ const getFreshStaffActor = (
req,
)
const getFreshCoordinatorActor = (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> =>
reloadCoordinatorActor(
payload,
actor,
'Somente o Coordenador Geral pode importar apoiadores.',
req,
)
export const assertMunicipalityManagement = async (
payload: Payload,
actor: CampaignUser,
@ -314,295 +266,28 @@ export const setSupporterVoteIntention = async (input: unknown) => {
return setSupporterVoteIntentionRecord(payload, actor, input)
}
const normalizeImportHeader = (header: string): string =>
header
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '_')
const mapImportHeader = (header: string): string | null => {
const normalized = normalizeImportHeader(header)
const aliases: Record<string, string> = {
nome: 'nome',
name: 'nome',
telefone: 'telefone',
phone: 'telefone',
celular: 'telefone',
municipio: 'municipio',
cidade: 'municipio',
city: 'municipio',
intencao: 'intencao',
intencao_de_voto: 'intencao',
vote_intention: 'intencao',
}
return aliases[normalized] ?? null
}
const parseVoteIntentionCell = (value: string): SupporterVoteIntention | undefined | 'invalid' => {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
const aliases: Record<string, SupporterVoteIntention> = {
certo: 'certo',
tende_a_certo: 'tende_a_certo',
tende: 'tende_a_certo',
indeciso: 'indeciso',
outro: 'outro',
}
return aliases[normalized] ?? 'invalid'
}
// ---------------------------------------------------------------------------
// CSV import pipeline — implementation lives in ./supporterImport. A top-level
// `'use server'` module cannot re-export values (only async functions defined
// here), so these async wrappers keep the import sites of this module stable.
// ---------------------------------------------------------------------------
export const previewSupporterImportText = async (
payload: Payload,
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
const currentActor = await getFreshCoordinatorActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
) => previewSupporterImportTextAction(payload, actor, csvText)
let records: Record<string, string>[]
try {
records = parse(csvText, {
columns: (headers: string[]) =>
headers.map((header) => {
const mapped = mapImportHeader(header)
if (!mapped) return `__unknown_${header}`
return mapped
}),
skip_empty_lines: true,
trim: true,
bom: true,
relax_column_count: true,
}) as Record<string, string>[]
} catch {
throw new Error('Não foi possível ler o CSV. Verifique o formato e tente novamente.')
}
if (records.length === 0) {
throw new Error('O CSV está vazio.')
}
if (records.length > MAX_IMPORT_ROWS) {
throw new Error(`O CSV excede o limite de ${MAX_IMPORT_ROWS} linhas.`)
}
const unknownColumns = Object.keys(records[0] ?? {}).filter((key) => key.startsWith('__unknown_'))
if (unknownColumns.length > 0) {
throw new Error(
`Colunas não reconhecidas no CSV: ${unknownColumns
.map((column) => column.replace(/^__unknown_/, ''))
.join(', ')}. Use apenas nome, telefone, municipio e intencao.`,
)
}
const phonesInFile = new Set<string>()
const rows: SupporterImportPreviewRow[] = []
for (const [index, record] of records.entries()) {
const line = index + 2
const nome = String(record.nome ?? '').trim()
const telefoneRaw = String(record.telefone ?? '').trim()
const municipioRaw = String(record.municipio ?? '').trim()
const intencaoRaw = String(record.intencao ?? '').trim()
const base: SupporterImportPreviewRowBase = {
line,
nome,
telefone: telefoneRaw,
municipio: municipioRaw,
intencao: intencaoRaw,
}
if (nome.length < 2) {
rows.push({ ...base, status: 'nome_invalido' })
continue
}
const normalizedPhone = normalizeBrazilianPhone(telefoneRaw)
if (!normalizedPhone) {
rows.push({ ...base, status: 'telefone_invalido' })
continue
}
let canonicalCity: string | undefined
if (municipioRaw) {
const city = resolveBahiaMunicipality(municipioRaw)
if (!city) {
rows.push({ ...base, status: 'municipio_nao_reconhecido', normalizedPhone })
continue
}
canonicalCity = city
}
const voteIntention = parseVoteIntentionCell(intencaoRaw)
if (voteIntention === 'invalid') {
rows.push({ ...base, status: 'intencao_invalida', normalizedPhone, canonicalCity })
continue
}
if (phonesInFile.has(normalizedPhone)) {
rows.push({
...base,
status: 'duplicado_pelo_telefone',
normalizedPhone,
canonicalCity,
voteIntention,
})
continue
}
phonesInFile.add(normalizedPhone)
rows.push({
...base,
status: 'ok',
normalizedPhone,
canonicalCity,
voteIntention,
})
}
// Duplicate against an existing supporter without municipality (Contact reuse alone is OK).
const candidatePhones = [
...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)),
]
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
depth: 0,
limit: candidatePhones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
})
const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const))
const contactIDs = [...contactByPhone.values()]
if (contactIDs.length > 0) {
const existingSupporters = await payload.find({
collection: 'supporter',
where: {
and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }],
},
depth: 0,
limit: contactIDs.length,
pagination: false,
select: { contact: true },
overrideAccess: true,
})
const supporterContactIDs = new Set(
existingSupporters.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
if (!row || !isSupporterImportOkRow(row)) continue
const contactID = contactByPhone.get(row.normalizedPhone)
if (contactID && supporterContactIDs.has(contactID)) {
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
}
}
}
}
const counts = {
ok: rows.filter(isSupporterImportOkRow).length,
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
error: rows.filter((row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone')
.length,
}
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) => {
const { payload, actor } = await getCampaignActionContext()
return previewSupporterImportText(payload, actor, csvText)
}
export const previewSupporterImport = async (csvText: string) =>
previewSupporterImportAction(csvText)
export const confirmSupporterImportRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterImportConfirmSchema.parse(input)
) => confirmSupporterImportRecordAction(payload, actor, 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 }) => {
const currentActor = await getFreshCoordinatorActor(payload, actor, req)
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
const phones = [...new Set(batch.okRows.map((row) => row.telefone))]
await acquireContactPhoneLocks(payload, req, phones)
const consentNote =
data.consentNote ??
'Importação CSV com atestado do operador sobre consentimento dos titulares.'
const result = await bulkInsertSupporterImport({
payload,
req,
actorID: currentActor.id,
rows: batch.okRows,
registrationConsent,
voteIntentionConsent,
consentNote,
})
// 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.' },
)
}
export const confirmSupporterImport = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return confirmSupporterImportRecord(payload, actor, input)
}
export const confirmSupporterImport = async (input: unknown) => confirmSupporterImportAction(input)
const contactHasOtherJoins = async (
payload: Payload,

View file

@ -0,0 +1,357 @@
'use server'
import { parse } from 'csv-parse/sync'
import type { Payload } from 'payload'
import {
resolveBahiaMunicipality,
supporterImportConfirmSchema,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCoordinatorActor } from '@/utilities/campaignActionContext'
import {
requireSupporterRegistrationConsent,
requireSupporterVoteIntentionConsent,
} from '@/utilities/campaignConsent'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { relationshipId } from '@/utilities/relationship'
import {
isPreviewErrorRow,
isSupporterImportOkRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
type SupporterImportPreviewRowBase,
} from '@/utilities/supporterImport'
import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk'
import {
deleteSupporterImportBatch,
issueSupporterImportToken,
loadSupporterImportBatch,
storeSupporterImportBatch,
verifySupporterImportToken,
} from '@/utilities/supporterImportToken'
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 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)},${row.status}`,
)
.join('\n')
return header + body
}
const getFreshCoordinatorActor = (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> =>
reloadCoordinatorActor(
payload,
actor,
'Somente o Coordenador Geral pode importar apoiadores.',
req,
)
const normalizeImportHeader = (header: string): string =>
header
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '_')
const mapImportHeader = (header: string): string | null => {
const normalized = normalizeImportHeader(header)
const aliases: Record<string, string> = {
nome: 'nome',
name: 'nome',
telefone: 'telefone',
phone: 'telefone',
celular: 'telefone',
municipio: 'municipio',
cidade: 'municipio',
city: 'municipio',
intencao: 'intencao',
intencao_de_voto: 'intencao',
vote_intention: 'intencao',
}
return aliases[normalized] ?? null
}
const parseVoteIntentionCell = (value: string): SupporterVoteIntention | undefined | 'invalid' => {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
const aliases: Record<string, SupporterVoteIntention> = {
certo: 'certo',
tende_a_certo: 'tende_a_certo',
tende: 'tende_a_certo',
indeciso: 'indeciso',
outro: 'outro',
}
return aliases[normalized] ?? 'invalid'
}
export const previewSupporterImportText = async (
payload: Payload,
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
const currentActor = await getFreshCoordinatorActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
let records: Record<string, string>[]
try {
records = parse(csvText, {
columns: (headers: string[]) =>
headers.map((header) => {
const mapped = mapImportHeader(header)
if (!mapped) return `__unknown_${header}`
return mapped
}),
skip_empty_lines: true,
trim: true,
bom: true,
relax_column_count: true,
}) as Record<string, string>[]
} catch {
throw new Error('Não foi possível ler o CSV. Verifique o formato e tente novamente.')
}
if (records.length === 0) {
throw new Error('O CSV está vazio.')
}
if (records.length > MAX_IMPORT_ROWS) {
throw new Error(`O CSV excede o limite de ${MAX_IMPORT_ROWS} linhas.`)
}
const unknownColumns = Object.keys(records[0] ?? {}).filter((key) => key.startsWith('__unknown_'))
if (unknownColumns.length > 0) {
throw new Error(
`Colunas não reconhecidas no CSV: ${unknownColumns
.map((column) => column.replace(/^__unknown_/, ''))
.join(', ')}. Use apenas nome, telefone, municipio e intencao.`,
)
}
const phonesInFile = new Set<string>()
const rows: SupporterImportPreviewRow[] = []
for (const [index, record] of records.entries()) {
const line = index + 2
const nome = String(record.nome ?? '').trim()
const telefoneRaw = String(record.telefone ?? '').trim()
const municipioRaw = String(record.municipio ?? '').trim()
const intencaoRaw = String(record.intencao ?? '').trim()
const base: SupporterImportPreviewRowBase = {
line,
nome,
telefone: telefoneRaw,
municipio: municipioRaw,
intencao: intencaoRaw,
}
if (nome.length < 2) {
rows.push({ ...base, status: 'nome_invalido' })
continue
}
const normalizedPhone = normalizeBrazilianPhone(telefoneRaw)
if (!normalizedPhone) {
rows.push({ ...base, status: 'telefone_invalido' })
continue
}
let canonicalCity: string | undefined
if (municipioRaw) {
const city = resolveBahiaMunicipality(municipioRaw)
if (!city) {
rows.push({ ...base, status: 'municipio_nao_reconhecido', normalizedPhone })
continue
}
canonicalCity = city
}
const voteIntention = parseVoteIntentionCell(intencaoRaw)
if (voteIntention === 'invalid') {
rows.push({ ...base, status: 'intencao_invalida', normalizedPhone, canonicalCity })
continue
}
if (phonesInFile.has(normalizedPhone)) {
rows.push({
...base,
status: 'duplicado_pelo_telefone',
normalizedPhone,
canonicalCity,
voteIntention,
})
continue
}
phonesInFile.add(normalizedPhone)
rows.push({
...base,
status: 'ok',
normalizedPhone,
canonicalCity,
voteIntention,
})
}
// Duplicate against an existing supporter without municipality (Contact reuse alone is OK).
const candidatePhones = [
...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)),
]
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
depth: 0,
limit: candidatePhones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
})
const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const))
const contactIDs = [...contactByPhone.values()]
if (contactIDs.length > 0) {
const existingSupporters = await payload.find({
collection: 'supporter',
where: {
and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }],
},
depth: 0,
limit: contactIDs.length,
pagination: false,
select: { contact: true },
overrideAccess: true,
})
const supporterContactIDs = new Set(
existingSupporters.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
if (!row || !isSupporterImportOkRow(row)) continue
const contactID = contactByPhone.get(row.normalizedPhone)
if (contactID && supporterContactIDs.has(contactID)) {
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
}
}
}
}
const counts = {
ok: rows.filter(isSupporterImportOkRow).length,
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
error: rows.filter((row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone')
.length,
}
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) => {
const { payload, actor } = await getCampaignActionContext()
return previewSupporterImportText(payload, actor, csvText)
}
export const confirmSupporterImportRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
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 }) => {
const currentActor = await getFreshCoordinatorActor(payload, actor, req)
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
const phones = [...new Set(batch.okRows.map((row) => row.telefone))]
await acquireContactPhoneLocks(payload, req, phones)
const consentNote =
data.consentNote ??
'Importação CSV com atestado do operador sobre consentimento dos titulares.'
const result = await bulkInsertSupporterImport({
payload,
req,
actorID: currentActor.id,
rows: batch.okRows,
registrationConsent,
voteIntentionConsent,
consentNote,
})
// 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.' },
)
}
export const confirmSupporterImport = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return confirmSupporterImportRecord(payload, actor, input)
}

View file

@ -1,6 +1,6 @@
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { RefreshRouteOnSave } from '@/utilities/RefreshRouteOnSave'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { Metadata } from 'next'
import React from 'react'
import './styles.css'

View file

@ -12,7 +12,7 @@ import {
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
const MAX_LEADERSHIP_PLAZAS = 30
const MAX_LEADERSHIP_MUNICIPALITIES = 30
const requireAtLeastOneMunicipality: CollectionBeforeValidateHook = ({
data,
@ -91,7 +91,7 @@ export const Leadership: CollectionConfig = {
required: true,
hasMany: true,
index: true,
maxRows: MAX_LEADERSHIP_PLAZAS,
maxRows: MAX_LEADERSHIP_MUNICIPALITIES,
},
{
name: 'organizations',

View file

@ -18,7 +18,7 @@ import {
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId } from '@/utilities/relationship'
const DERIVED_PLAZA_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
const nonEmptyText = (value: unknown): boolean =>
typeof value === 'string' && value.trim().length > 0
@ -75,7 +75,7 @@ const recomputeMunicipalityLastUpdateAt = async (req: PayloadRequest, municipali
depth: 0,
overrideAccess: true,
context: {
[DERIVED_PLAZA_UPDATE_CONTEXT]: true,
[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]: true,
},
req,
})
@ -89,7 +89,7 @@ const lockMunicipalitiesBeforeChange: CollectionBeforeChangeHook = async ({
originalDoc,
req,
}) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return data
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return data
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.author = req.user.id
@ -106,7 +106,7 @@ const lockMunicipalitiesBeforeChange: CollectionBeforeChangeHook = async ({
}
const lockMunicipalityBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return
const doc = await req.payload.findByID({
collection: 'municipalityUpdate',
@ -127,7 +127,7 @@ const recomputeChangedMunicipalities: CollectionAfterChangeHook = async ({
previousDoc,
req,
}) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return doc
const municipalityIDs = uniqueSortedMunicipalityIDs([
relationshipId(previousDoc?.municipality),
@ -141,7 +141,7 @@ const recomputeChangedMunicipalities: CollectionAfterChangeHook = async ({
}
const recomputeDeletedMunicipality: CollectionAfterDeleteHook = async ({ context, doc, req }) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return doc
const municipalityIDs = uniqueSortedMunicipalityIDs([relationshipId(doc.municipality)])
if (municipalityIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500)

View file

@ -35,14 +35,14 @@ export type SupportStatus = (typeof leadershipSupportStatuses)[number]
export const isSupportStatus = (value: unknown): value is SupportStatus =>
typeof value === 'string' && leadershipSupportStatuses.some((status) => status === value)
export const MAX_LEADERSHIP_PLAZAS = 30
export const MAX_LEADERSHIP_MUNICIPALITIES = 30
export const MAX_LEADERSHIP_ORGANIZATIONS = 20
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20
const municipalitiesArraySchema = z
.array(positiveRelationshipId)
.min(1, 'Vincule a liderança a pelo menos uma Praça.')
.max(MAX_LEADERSHIP_PLAZAS)
.max(MAX_LEADERSHIP_MUNICIPALITIES)
.transform((ids) => [...new Set(ids)])
const organizationsArraySchema = z

View file

@ -0,0 +1,69 @@
// ---------------------------------------------------------------------------
// Action plans
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, Where } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
const canStaffCreateActionPlan: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canCreateActionPlan: Access = canStaffCreateActionPlan
export const canReadActionPlan: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role === 'advisor') {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
or: [
{
advisors: {
contains: currentUser.id,
},
},
{
municipality: {
in: municipalityIDs ?? [],
},
},
],
}
}
return false
}
/** Same row scope as read — leader write limits are enforced in `beforeChange`. */
export const canUpdateActionPlan: Access = canReadActionPlan
export const canDeleteActionPlan: Access = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanStatus: FieldAccess = canStaffCreateActionPlan
export const canCreateActionPlanAdvisors: FieldAccess = canStaffCreateActionPlan
export const canManageActionPlanAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))
}

View file

@ -0,0 +1,153 @@
// ---------------------------------------------------------------------------
// Campaign users
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, PayloadRequest } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { DynamicFind } from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess'
export const canManageCampaignUsers: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canManageCampaignUserRole: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canReadCampaignUsers: Access = ({ req }) =>
isPayloadAdmin(req.user) || isCampaignUser(req.user)
export const canReadCampaignUserIdentity: FieldAccess = ({ req, id }) => {
if (isPayloadAdmin(req.user)) return true
if (!isCampaignUser(req.user)) return false
return id !== undefined && String(id) === String(req.user.id)
}
const canSelfOrStaffUpdateCampaignUser = async (
req: PayloadRequest,
id: string | number | undefined,
): Promise<boolean> => {
if (isPayloadAdmin(req.user)) return true
if (isCampaignCoordinator(await getFreshCampaignUser(req))) return true
if (!isCampaignUser(req.user) || id === undefined) return false
return String(id) === String(req.user.id)
}
export const canUpdateCampaignUser: Access = async ({ req, id }) =>
canSelfOrStaffUpdateCampaignUser(req, id)
export const canUpdateCampaignUserAvatar: FieldAccess = async ({ req, id }) =>
canSelfOrStaffUpdateCampaignUser(req, id)
// ---------------------------------------------------------------------------
// Staff-only field access (internal evaluations, estimates, financials)
// ---------------------------------------------------------------------------
export const canReadCampaignStaffField: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canManageCampaignStaffField: FieldAccess = canReadCampaignStaffField
/** System/derived fields are written only via server actions with overrideAccess. */
export const canSetCampaignSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
// ---------------------------------------------------------------------------
// Campaign user phone visibility
// ---------------------------------------------------------------------------
export const canReadCampaignUserPhone: FieldAccess = async ({ doc, id, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (id !== undefined && String(id) === String(currentUser.id)) return true
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (id === undefined) return false
const targetUserID = Number(id)
if (!Number.isInteger(targetUserID) || targetUserID <= 0) return false
const context = req.context as Record<string, unknown>
const cacheKey = `${CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY}:${currentUser.id}:${targetUserID}`
if (typeof context[cacheKey] === 'boolean') return context[cacheKey]
let targetRole = typeof doc === 'object' && doc !== null && 'role' in doc ? doc.role : undefined
if (targetRole === undefined) {
try {
const target = await req.payload.findByID({
collection: 'campaignUser',
id: targetUserID,
depth: 0,
select: { role: true },
overrideAccess: true,
req,
})
targetRole = target.role
} catch {
// Missing target only — fall through to the municipality-scope check.
}
}
if (targetRole === 'coordinator') {
context[cacheKey] = true
return true
}
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
if (!municipalityIDs?.length) {
context[cacheKey] = false
return false
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'municipality',
depth: 0,
limit: 1,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
and: [{ id: { in: municipalityIDs } }, { advisors: { contains: targetUserID } }],
},
})
const allowed = result.docs.length > 0
context[cacheKey] = allowed
return allowed
}
export const canCreateCampaignUserPhone: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))
}
export const canUpdateCampaignUserPhone: FieldAccess = async ({ id, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (id !== undefined && String(id) === String(currentUser.id)) return true
return isCampaignCoordinator(currentUser)
}

View file

@ -0,0 +1,114 @@
// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
import type { Access, PayloadRequest } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { CampaignActor, DynamicFind } from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type ContactID = number
const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds'
export const getAccessibleContactIds = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<ContactID[]> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return []
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const leadershipWhere =
currentUser.role === 'leader'
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
}
: {
municipalities: {
in: municipalityIDs ?? [],
},
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const collections = req.payload.collections as Record<string, unknown>
const contactIDs: ContactID[] = []
if (collections.leadership) {
const leadershipResult = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: leadershipWhere,
})
for (const doc of leadershipResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
if (currentUser.role === 'advisor' && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: {
municipality: {
in: municipalityIDs ?? [],
},
},
})
for (const doc of supporterResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
const ids = [...new Set(contactIDs)]
context[cacheKey] = ids
return ids
}
export const canReadContacts: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser) return false
const ids = await getAccessibleContactIds(req, currentUser)
return {
id: {
in: ids,
},
}
}
export const canManageContacts: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,53 @@
// ---------------------------------------------------------------------------
// Campaign demands
// ---------------------------------------------------------------------------
import type { Access, Where } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
export const canCreateCampaignDemand: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
const municipalityID = relationshipId(data?.municipality)
if (!municipalityID) return false
if (currentUser.role !== 'advisor') return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || currentUser.role !== 'advisor') return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipality: {
in: municipalityIDs ?? [],
},
}
}
/**
* Staff manage demands in their municipalities; leaders have no demand access.
*/
export const canUpdateCampaignDemand: Access = canReadCampaignDemand
export const canDeleteCampaignDemand: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,26 @@
// ---------------------------------------------------------------------------
// Election data
// ---------------------------------------------------------------------------
import type { CampaignUser, User } from '@/payload-types'
import type { Access } from 'payload'
import type { CampaignActor } from '@/utilities/access/shared'
import { isCampaignStaff, isPayloadAdmin } from '@/utilities/access/shared'
export type ElectionDataReader = CampaignUser | User
export const canReadElectionDataAsUser = (user: CampaignActor): user is ElectionDataReader =>
isPayloadAdmin(user) || isCampaignStaff(user)
export function assertCanReadElectionData(user: CampaignActor): asserts user is ElectionDataReader {
if (!canReadElectionDataAsUser(user)) {
throw new Error('Leitura de dados eleitorais negada.')
}
}
/** Public TSE election data: any authenticated campaign or admin user may read. */
export const canReadElectionData: Access = ({ req }) => canReadElectionDataAsUser(req.user)
/** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */
export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,47 @@
// ---------------------------------------------------------------------------
// Campaign invites
// ---------------------------------------------------------------------------
import type { Access, FieldAccess } from 'payload'
import { getAccessibleLeadershipIds } from '@/utilities/access/leaderships'
import {
getFreshCampaignUser,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
export const canCreateCampaignInvite: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser?.role !== 'advisor') return false
const leadershipID = relationshipId(data?.leadership)
if (!leadershipID) return false
const accessibleLeadershipIDs = await getAccessibleLeadershipIds(req, currentUser)
return accessibleLeadershipIDs?.includes(leadershipID) ?? false
}
export const canReadCampaignInvite: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser?.role !== 'advisor') return false
const accessibleLeadershipIDs = await getAccessibleLeadershipIds(req, currentUser)
return {
leadership: {
in: accessibleLeadershipIDs ?? [],
},
}
}
export const canMutateCampaignInvite: Access = ({ req }) => isPayloadAdmin(req.user)
export const canSetCampaignInviteSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,135 @@
// ---------------------------------------------------------------------------
// Leaderships
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, PayloadRequest } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { CampaignActor, DynamicFind } from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type LeadershipID = number
type AccessibleLeadershipIDs = LeadershipID[] | null
const ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY = 'campaignAccessibleLeadershipIds'
export const canSetAdministrativeLeadershipField: FieldAccess = ({ req }) =>
isPayloadAdmin(req.user)
const municipalityIDsFromData = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
export const canReadLeadership: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser) return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipalities: {
in: municipalityIDs ?? [],
},
}
}
export const canCreateLeadership: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser?.role !== 'advisor') return false
const requestedMunicipalityIDs = municipalityIDsFromData(data?.municipalities)
if (requestedMunicipalityIDs.length === 0) return false
const accessibleMunicipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
if (accessibleMunicipalityIDs === null) return true
return requestedMunicipalityIDs.every((id) => accessibleMunicipalityIDs.includes(id))
}
export const canManageLeadership: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser?.role !== 'advisor') return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipalities: {
in: municipalityIDs ?? [],
},
}
}
export const canDeleteLeadership: Access = ({ req }) => isPayloadAdmin(req.user)
/**
* Returns null for unrestricted roles, otherwise the leadership IDs
* in the actor's scope: own engaged record for a leader, leaderships linked to
* administered municipalities for an advisor.
*/
export const getAccessibleLeadershipIds = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<AccessibleLeadershipIDs> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return []
if (isCampaignUnrestricted(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const collections = req.payload.collections as Record<string, unknown>
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
let ids: LeadershipID[] = []
if (currentUser.role === 'leader') {
ids = []
}
if (currentUser.role === 'advisor' && collections.leadership) {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const result = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
municipalities: {
in: municipalityIDs ?? [],
},
},
})
ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
}
const uniqueIDs = [...new Set(ids)]
context[cacheKey] = uniqueIDs
return uniqueIDs
}

View file

@ -0,0 +1,225 @@
// ---------------------------------------------------------------------------
// Municipalities (Praças)
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, Payload, PayloadRequest } from 'payload'
import type {
CampaignActor,
CampaignTransactionRequest,
DynamicFind,
} from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignCoordinator,
isCampaignLeader,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type MunicipalityID = number
type AccessibleMunicipalityIDs = MunicipalityID[] | null
const ACCESSIBLE_MUNICIPALITY_IDS_CONTEXT_KEY = 'campaignAccessibleMunicipalityIds'
const OWN_LEADERSHIP_CONTEXT_KEY = 'campaignOwnLeadership'
/**
* Municipality IDs where `advisorID` is an assigned advisor, without requiring a
* `PayloadRequest`. Canonical implementation of the "advisors contains
* user.id" lookup; `getAccessibleMunicipalityIds` uses it for its (request-scoped,
* context-cached) advisor branch.
*/
export const getAdvisorMunicipalityIds = async (
payload: Pick<Payload, 'find'>,
advisorID: number,
req?: CampaignTransactionRequest,
): Promise<number[]> => {
const find = payload.find.bind(payload) as unknown as DynamicFind
const result = await find({
collection: 'municipality',
where: { advisors: { contains: advisorID } },
depth: 0,
limit: 0,
pagination: false,
select: { id: true },
overrideAccess: true,
...(req ? { req } : {}),
})
return result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
}
/**
* Municipality IDs linked to `userID`'s engaged leadership, without requiring a
* `PayloadRequest`. Transaction-safe counterpart of the leader branch in
* `getAccessibleMunicipalityIds`, for server actions running inside
* `withPayloadTransaction` (where only `{ transactionID }` is available).
*/
export const getEngagedLeaderMunicipalityIds = async (
payload: Pick<Payload, 'find'>,
userID: number,
req?: CampaignTransactionRequest,
): Promise<number[]> => {
const find = payload.find.bind(payload) as unknown as DynamicFind
const result = await find({
collection: 'leadership',
where: {
and: [{ user: { equals: userID } }, { supportStatus: { equals: 'engajado' } }],
},
depth: 0,
limit: 1,
pagination: false,
select: { municipalities: true },
overrideAccess: true,
...(req ? { req } : {}),
})
const doc = result.docs[0]
return (Array.isArray(doc?.municipalities) ? doc.municipalities : [])
.map(relationshipId)
.filter((id): id is number => id !== null)
}
type OwnLeadership = { id: number; municipalityIDs: number[]; organizationIDs: number[] } | null
/**
* The engaged leadership record linked to the authenticated leader account
* (contact is unique per person, so there is at most one). Cached per request.
*/
export const getOwnEngagedLeadership = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<OwnLeadership> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${OWN_LEADERSHIP_CONTEXT_KEY}:${currentUser.id}`
if (cacheKey in context) return context[cacheKey] as OwnLeadership
const collections = req.payload.collections as Record<string, unknown>
let ownLeadership: OwnLeadership = null
if (collections.leadership) {
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'leadership',
depth: 0,
limit: 1,
overrideAccess: true,
pagination: false,
req,
select: { municipalities: true, organizations: true },
where: {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
},
})
const doc = result.docs[0]
if (doc) {
const id = relationshipId(doc.id)
if (id !== null) {
ownLeadership = {
id,
municipalityIDs: (Array.isArray(doc.municipalities) ? doc.municipalities : [])
.map(relationshipId)
.filter((value): value is number => value !== null),
organizationIDs: (Array.isArray(doc.organizations) ? doc.organizations : [])
.map(relationshipId)
.filter((value): value is number => value !== null),
}
}
}
}
context[cacheKey] = ownLeadership
return ownLeadership
}
/**
* Returns null for the unrestricted coordinator, otherwise the municipality IDs the
* authenticated campaign user can operate on: administered municipalities for an
* advisor, linked (engaged) municipalities for a leader.
*/
export const getAccessibleMunicipalityIds = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<AccessibleMunicipalityIDs> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return []
if (isCampaignUnrestricted(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_MUNICIPALITY_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const collections = req.payload.collections as Record<string, unknown>
let ids: MunicipalityID[] = []
if (currentUser.role === 'advisor' && collections.municipality) {
ids = await getAdvisorMunicipalityIds(req.payload, currentUser.id, req)
}
if (currentUser.role === 'leader') {
const ownLeadership = await getOwnEngagedLeadership(req, currentUser)
ids = ownLeadership?.municipalityIDs ?? []
}
const uniqueIDs = [...new Set(ids)]
context[cacheKey] = uniqueIDs
return uniqueIDs
}
/** Municipalities are seeded by migration; nobody creates or deletes them in the app. */
export const canCreateMunicipality: Access = ({ req }) => isPayloadAdmin(req.user)
export const canReadMunicipality: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignLeader(currentUser)) return false
const ids = await getAccessibleMunicipalityIds(req)
if (ids === null) return true
return {
id: {
in: ids,
},
}
}
export const canUpdateMunicipality: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser?.role !== 'advisor') return false
return {
advisors: {
contains: currentUser.id,
},
}
}
export const canDeleteMunicipality: Access = ({ req }) => isPayloadAdmin(req.user)
/** Advisor assignment is coordinator-only (server actions use overrideAccess). */
export const canAssignMunicipalityAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))
}
export const canManageMunicipalityAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,48 @@
// ---------------------------------------------------------------------------
// Municipality updates (immutable field reports)
// ---------------------------------------------------------------------------
import type { Access, FieldAccess } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
export const canCreateMunicipalityUpdate: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
const municipalityID = relationshipId(data?.municipality)
if (!municipalityID) return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
export const canReadMunicipalityUpdate: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser) return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipality: {
in: municipalityIDs ?? [],
},
}
}
export const canMutateMunicipalityUpdate: Access = ({ req }) => isPayloadAdmin(req.user)
export const canSetMunicipalityUpdateAuthor: FieldAccess = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,36 @@
// ---------------------------------------------------------------------------
// Organizations
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignStaff,
isPayloadAdmin,
} from '@/utilities/access/shared'
export const canCreateOrganization: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canReadOrganization: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignStaff(currentUser)) return true
return false
}
export const canManageOrganization: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canDeleteOrganization: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,90 @@
// ---------------------------------------------------------------------------
// Shared internals of the campaign RBAC modules (`src/utilities/access/*`).
// Domain modules may import from here; this module must not import from them.
// ---------------------------------------------------------------------------
import type { CampaignUser, User } from '@/payload-types'
import type { Access, PayloadRequest, Where } from 'payload'
export type CampaignActor = CampaignUser | User | null | undefined
const FRESH_CAMPAIGN_USER_CONTEXT_KEY = 'campaignFreshUser'
/** Enough of a request for Local API calls inside an existing transaction. */
export type CampaignTransactionRequest = PayloadRequest | { transactionID: number | string }
export type DynamicFind = (args: {
collection: string
depth: number
limit: number
overrideAccess: true
pagination: false
req?: CampaignTransactionRequest
select: Record<string, true>
where: Record<string, unknown>
}) => Promise<{ docs: Array<Record<string, unknown>> }>
export const isPayloadAdmin = (user: CampaignActor): user is User => user?.collection === 'users'
/**
* Payload-admin-only collection access. Collections without explicit access fall
* back to Payload's "any authenticated user" default — which includes campaign
* users hitting `/api/*` with a `campaign-token` JWT — so every CMS/PII
* collection must set this (or something stricter) explicitly.
*/
export const payloadAdminOnly: Access = ({ req }) => isPayloadAdmin(req.user)
export const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
user?.collection === 'campaignUser'
/** "Coordenador Geral" — unrestricted campaign coordination. */
export const isCampaignCoordinator = (user: CampaignActor): boolean =>
isCampaignUser(user) && user.role === 'coordinator'
/** "Candidato" — full campaign visibility (superset of coordinator for reads). */
export const isCampaignCandidate = (user: CampaignActor): boolean =>
isCampaignUser(user) && user.role === 'candidate'
/** Coordinator or candidate — unrestricted scope (all municipalities, decisions). */
export const isCampaignUnrestricted = (user: CampaignActor): boolean =>
isCampaignCoordinator(user) || isCampaignCandidate(user)
export const isCampaignLeader = (user: CampaignActor): boolean =>
isCampaignUser(user) && user.role === 'leader'
/** Staff = coordinator, advisor, or candidate. Leaders are not staff. */
export const isCampaignStaff = (user: CampaignActor): boolean =>
isCampaignUser(user) &&
(user.role === 'coordinator' || user.role === 'advisor' || user.role === 'candidate')
/** Eligible relationship targets for advisor assignments (municipality / action plan). */
export const eligibleCampaignStaffWhere: Where = {
or: [{ role: { equals: 'coordinator' } }, { role: { equals: 'advisor' } }],
}
export const getFreshCampaignUser = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<CampaignUser | null> => {
if (!isCampaignUser(user)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${FRESH_CAMPAIGN_USER_CONTEXT_KEY}:${user.id}`
if (cacheKey in context) return context[cacheKey] as CampaignUser | null
let fresh: CampaignUser | null = null
try {
fresh = await req.payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 0,
overrideAccess: true,
req,
})
} catch {
fresh = null
}
context[cacheKey] = fresh
return fresh
}

View file

@ -0,0 +1,23 @@
// ---------------------------------------------------------------------------
// State deputies (dobradinhas)
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import { getFreshCampaignUser, isCampaignStaff, isPayloadAdmin } from '@/utilities/access/shared'
export const canCreateStateDeputy: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canReadStateDeputy: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canManageStateDeputy: Access = canReadStateDeputy
export const canDeleteStateDeputy: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,67 @@
// ---------------------------------------------------------------------------
// Supporters
// ---------------------------------------------------------------------------
import type { Access, Where } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
export const canCreateSupporter: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
const municipalityID = relationshipId(data?.municipality)
if (!municipalityID) return false
if (currentUser?.role === 'advisor') {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
if (isCampaignLeader(currentUser)) {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
return false
}
export const canReadSupporter: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role === 'advisor') {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipality: {
in: municipalityIDs ?? [],
},
}
}
if (isCampaignLeader(currentUser)) {
return {
createdBy: {
equals: currentUser.id,
},
}
}
return false
}
export const canManageSupporter: Access = canReadSupporter
export const canDeleteSupporter: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,55 @@
// ---------------------------------------------------------------------------
// Vote pledges (declared by staff, estimated by staff)
// ---------------------------------------------------------------------------
import type { Access, Where } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
export const canCreateVotePledge: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
const municipalityID = relationshipId(data?.municipality)
const leadershipID = relationshipId(data?.leadership)
if (!municipalityID || !leadershipID) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role === 'advisor') {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
return false
}
export const canReadVotePledge: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || currentUser.role !== 'advisor') return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipality: {
in: municipalityIDs ?? [],
},
}
}
/** Same row scope as read — the estimated fields are gated by field access. */
export const canUpdateVotePledge: Access = canReadVotePledge
export const canDeleteVotePledge: Access = ({ req }) => isPayloadAdmin(req.user)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
import 'server-only'
import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { reloadStaffActor } from '@/utilities/campaignActionContext'
/**
* Shared policy for simple staff-managed reference entities (organizações,
* dobradinhas): fresh staff assert → run the caller's concretely-typed
* mutation → translate unique-index conflicts into a safe user-facing message.
*/
export type StaffEntityPolicy = {
/** Thrown when the fresh actor is not campaign staff. */
staffMessage: string
/** Matches the collection's unique-index violation. */
conflictPattern: RegExp
/** Safe user-facing message for a unique conflict. */
conflictMessage: string
}
export const runStaffEntityMutation = async <Result>(
payload: Payload,
actor: CampaignUser,
policy: StaffEntityPolicy,
mutate: (currentActor: CampaignUser) => Promise<Result>,
): Promise<Result> => {
const currentActor = await reloadStaffActor(payload, actor, policy.staffMessage)
try {
return await mutate(currentActor)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (policy.conflictPattern.test(message)) {
throw new Error(policy.conflictMessage)
}
throw error
}
}

View file

@ -1,3 +1,4 @@
import { redirect } from 'next/navigation'
import { ZodError } from 'zod'
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
@ -48,3 +49,30 @@ export const mapCampaignFormActionError = <Values>(
}
return { message: genericMessage, values, revision }
}
/**
* The shared create→redirect form-action ladder: run the mutation, map any
* failure through `mapCampaignFormActionError`, redirect on success. The
* redirect happens OUTSIDE the try so Next's control-flow error is never
* swallowed by the mapper.
*/
export const runCampaignRedirectFormAction = async <Result>({
execute,
redirectTo,
safeMessages,
genericMessage,
}: {
execute: () => Promise<Result>
redirectTo: (result: Result) => string
safeMessages?: readonly string[]
genericMessage: string
}): Promise<CampaignFormActionState> => {
let target: string
try {
target = redirectTo(await execute())
} catch (error) {
return mapCampaignFormActionError({ error, safeMessages, genericMessage })
}
redirect(target)
}

View file

@ -185,7 +185,7 @@ export const municipalityListCoverageLabels: Record<NonNullable<MunicipalityList
sem_assessor: 'Sem assessor',
}
const MAX_PLAZA_LIST_VISIT_LABEL_LENGTH = 80
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
const parts: string[] = []
@ -200,8 +200,8 @@ export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): s
if (!parts.length) return null
const label = `Praças · ${parts.join(' · ')}`
if (label.length <= MAX_PLAZA_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_PLAZA_LIST_VISIT_LABEL_LENGTH - 1)}…`
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
}
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>

View file

@ -169,7 +169,7 @@ const combineErrors = (primary: unknown, cleanup: unknown): AggregateError =>
* concurrently running spec files must never operate on the same municipality. A
* Postgres sequence hands out globally unique catalog indexes.
*/
const PLAZA_ALLOCATION_SEQUENCE = 'campaign_fixture_municipality_alloc'
const MUNICIPALITY_ALLOCATION_SEQUENCE = 'campaign_fixture_municipality_alloc'
let allocationSequenceReady: Promise<void> | undefined
const nextAllocatedMunicipalityIndex = async (
@ -177,7 +177,7 @@ const nextAllocatedMunicipalityIndex = async (
catalogSize: number,
): Promise<number> => {
allocationSequenceReady ??= payload.db.drizzle
.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${PLAZA_ALLOCATION_SEQUENCE}"`))
.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${MUNICIPALITY_ALLOCATION_SEQUENCE}"`))
.then(() => undefined)
.catch((error: unknown) => {
// IF NOT EXISTS still races across parallel workers (pg_class unique
@ -187,7 +187,7 @@ const nextAllocatedMunicipalityIndex = async (
})
await allocationSequenceReady
const result = await payload.db.drizzle.execute(
sql.raw(`SELECT nextval('"${PLAZA_ALLOCATION_SEQUENCE}"') AS "value"`),
sql.raw(`SELECT nextval('"${MUNICIPALITY_ALLOCATION_SEQUENCE}"') AS "value"`),
)
const value = Number((result.rows[0] as { value: string | number }).value)
return value % catalogSize

View file

@ -31,7 +31,7 @@ const campaignFixtures = installCampaignFixtures({
const DUPLICATE_LEADERSHIP_MESSAGE =
'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novos municípios.'
const OUT_OF_SCOPE_PLAZA_MESSAGE = 'Você só pode vincular lideranças aos municípios que assessora.'
const OUT_OF_SCOPE_MUNICIPALITY_MESSAGE = 'Você só pode vincular lideranças aos municípios que assessora.'
describe('campaign leadership domain', () => {
beforeAll(async () => {
@ -374,7 +374,7 @@ describe('campaign leadership domain', () => {
phone: campaignFixtures().phone(),
supportStatus: 'engajado',
}),
).rejects.toThrow(OUT_OF_SCOPE_PLAZA_MESSAGE)
).rejects.toThrow(OUT_OF_SCOPE_MUNICIPALITY_MESSAGE)
})
it('enforces scoped action success and denial for create and internal update', async () => {
@ -403,7 +403,7 @@ describe('campaign leadership domain', () => {
id: own.id,
municipalities: [assigned.id, other.id],
}),
).rejects.toThrow(OUT_OF_SCOPE_PLAZA_MESSAGE)
).rejects.toThrow(OUT_OF_SCOPE_MUNICIPALITY_MESSAGE)
const otherLeadership = await createLeadershipRecord(payload, coordinator, {
municipalities: [other.id],

View file

@ -10,7 +10,7 @@ import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const ZONE_PLAZA_COUNT = municipalityCatalog.filter((entry) => entry.kind === 'zona').length
const ZONE_MUNICIPALITY_COUNT = municipalityCatalog.filter((entry) => entry.kind === 'zona').length
const ZONE_MUNICIPALITY_CODES = new Set(
municipalityCatalog.filter((entry) => entry.kind === 'zona').map((entry) => entry.ibgeCode),
)
@ -34,7 +34,7 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
const bundle = await loadMunicipalityMapBundle(payload, coordinator, { kind: 'zona' })
expect(bundle).not.toBeNull()
expect(bundle!.zoneBreakdown).toHaveLength(ZONE_PLAZA_COUNT)
expect(bundle!.zoneBreakdown).toHaveLength(ZONE_MUNICIPALITY_COUNT)
const ibgeCodes = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(ibgeCodes.length).toBeGreaterThan(0)