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:
parent
590d311364
commit
f23c2582ee
34 changed files with 1858 additions and 1531 deletions
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
357
src/app/(campaign)/campanha/actions/supporterImport.ts
Normal file
357
src/app/(campaign)/campanha/actions/supporterImport.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
src/utilities/access/actionPlans.ts
Normal file
69
src/utilities/access/actionPlans.ts
Normal 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))
|
||||
}
|
||||
153
src/utilities/access/campaignUsers.ts
Normal file
153
src/utilities/access/campaignUsers.ts
Normal 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)
|
||||
}
|
||||
114
src/utilities/access/contacts.ts
Normal file
114
src/utilities/access/contacts.ts
Normal 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)
|
||||
53
src/utilities/access/demands.ts
Normal file
53
src/utilities/access/demands.ts
Normal 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)
|
||||
26
src/utilities/access/elections.ts
Normal file
26
src/utilities/access/elections.ts
Normal 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)
|
||||
47
src/utilities/access/invites.ts
Normal file
47
src/utilities/access/invites.ts
Normal 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)
|
||||
135
src/utilities/access/leaderships.ts
Normal file
135
src/utilities/access/leaderships.ts
Normal 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
|
||||
}
|
||||
225
src/utilities/access/municipalities.ts
Normal file
225
src/utilities/access/municipalities.ts
Normal 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)
|
||||
48
src/utilities/access/municipalityUpdates.ts
Normal file
48
src/utilities/access/municipalityUpdates.ts
Normal 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)
|
||||
36
src/utilities/access/organizations.ts
Normal file
36
src/utilities/access/organizations.ts
Normal 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)
|
||||
90
src/utilities/access/shared.ts
Normal file
90
src/utilities/access/shared.ts
Normal 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
|
||||
}
|
||||
23
src/utilities/access/stateDeputies.ts
Normal file
23
src/utilities/access/stateDeputies.ts
Normal 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)
|
||||
67
src/utilities/access/supporters.ts
Normal file
67
src/utilities/access/supporters.ts
Normal 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)
|
||||
55
src/utilities/access/votePledges.ts
Normal file
55
src/utilities/access/votePledges.ts
Normal 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
39
src/utilities/campaignEntityActions.ts
Normal file
39
src/utilities/campaignEntityActions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue