From f23c2582eee7ec9c1c8b22e8188eaa70a399688c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 23 Jul 2026 23:44:45 -0300 Subject: [PATCH] Break up the god modules into deep domain modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/ --- .../(app)/demandas/nova/formActions.ts | 44 +- .../(app)/dobradinhas/nova/formActions.ts | 43 +- .../(app)/liderancas/nova/formActions.ts | 67 +- .../(app)/organizacoes/nova/formActions.ts | 52 +- .../campanha/actions/organization.ts | 48 +- .../campanha/actions/stateDeputy.ts | 48 +- .../(campaign)/campanha/actions/supporter.ts | 351 +---- .../campanha/actions/supporterImport.ts | 357 ++++++ src/app/(frontend)/layout.tsx | 2 +- src/collections/Leadership.ts | 4 +- src/collections/MunicipalityUpdate.ts | 12 +- .../RefreshRouteOnSave.tsx | 0 src/lib/schemas/leadership.ts | 4 +- src/utilities/access/actionPlans.ts | 69 + src/utilities/access/campaignUsers.ts | 153 +++ src/utilities/access/contacts.ts | 114 ++ src/utilities/access/demands.ts | 53 + src/utilities/access/elections.ts | 26 + src/utilities/access/invites.ts | 47 + src/utilities/access/leaderships.ts | 135 ++ src/utilities/access/municipalities.ts | 225 ++++ src/utilities/access/municipalityUpdates.ts | 48 + src/utilities/access/organizations.ts | 36 + src/utilities/access/shared.ts | 90 ++ src/utilities/access/stateDeputies.ts | 23 + src/utilities/access/supporters.ts | 67 + src/utilities/access/votePledges.ts | 55 + src/utilities/campaignAccess.ts | 1127 ++--------------- src/utilities/campaignEntityActions.ts | 39 + src/utilities/campaignFormActionError.ts | 28 + src/utilities/municipalityUi.ts | 6 +- tests/helpers/campaignFixtures.ts | 6 +- tests/int/campaignLeadership.int.spec.ts | 6 +- tests/int/municipalityMapData.int.spec.ts | 4 +- 34 files changed, 1858 insertions(+), 1531 deletions(-) create mode 100644 src/app/(campaign)/campanha/actions/supporterImport.ts rename src/{utilities => components}/RefreshRouteOnSave.tsx (100%) create mode 100644 src/utilities/access/actionPlans.ts create mode 100644 src/utilities/access/campaignUsers.ts create mode 100644 src/utilities/access/contacts.ts create mode 100644 src/utilities/access/demands.ts create mode 100644 src/utilities/access/elections.ts create mode 100644 src/utilities/access/invites.ts create mode 100644 src/utilities/access/leaderships.ts create mode 100644 src/utilities/access/municipalities.ts create mode 100644 src/utilities/access/municipalityUpdates.ts create mode 100644 src/utilities/access/organizations.ts create mode 100644 src/utilities/access/shared.ts create mode 100644 src/utilities/access/stateDeputies.ts create mode 100644 src/utilities/access/supporters.ts create mode 100644 src/utilities/access/votePledges.ts create mode 100644 src/utilities/campaignEntityActions.ts diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts index 267bd299..7c1da364 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts @@ -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 => { - 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts index 09189f55..8949ca92 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts @@ -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 => { - 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts index a4d54625..2bfbc536 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -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 => { - let createdId: number | null = null - try { - const sector = optionalFormText(formData, 'sector') - const supportStatus = optionalFormText(formData, 'supportStatus') +): Promise => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts index 851edac3..c2f9f12a 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts @@ -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 => { - 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/actions/organization.ts b/src/app/(campaign)/campanha/actions/organization.ts index d4bfc1e1..057a5120 100644 --- a/src/app/(campaign)/campanha/actions/organization.ts +++ b/src/app/(campaign)/campanha/actions/organization.ts @@ -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 => - 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) => { diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index dfb92d78..6e19c58d 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -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 => - 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) => { diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index b097c3e2..5cb1a397 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -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 => - 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 = { - 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 = { - 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 => { - const currentActor = await getFreshCoordinatorActor(payload, actor) - await requireSupporterRegistrationConsent(payload) - await requireSupporterVoteIntentionConsent(payload) +) => previewSupporterImportTextAction(payload, actor, csvText) - let records: Record[] - 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[] - } 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() - 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, diff --git a/src/app/(campaign)/campanha/actions/supporterImport.ts b/src/app/(campaign)/campanha/actions/supporterImport.ts new file mode 100644 index 00000000..3186308a --- /dev/null +++ b/src/app/(campaign)/campanha/actions/supporterImport.ts @@ -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 => + 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 = { + 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 = { + 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 => { + const currentActor = await getFreshCoordinatorActor(payload, actor) + await requireSupporterRegistrationConsent(payload) + await requireSupporterVoteIntentionConsent(payload) + + let records: Record[] + 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[] + } 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() + 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) +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index d6790999..616ecb9f 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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' diff --git a/src/collections/Leadership.ts b/src/collections/Leadership.ts index 9e4fdf02..0f24c0c6 100644 --- a/src/collections/Leadership.ts +++ b/src/collections/Leadership.ts @@ -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', diff --git a/src/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index c9f08fa6..dc32076c 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -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) diff --git a/src/utilities/RefreshRouteOnSave.tsx b/src/components/RefreshRouteOnSave.tsx similarity index 100% rename from src/utilities/RefreshRouteOnSave.tsx rename to src/components/RefreshRouteOnSave.tsx diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 4c4ee008..9f9b4c57 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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 diff --git a/src/utilities/access/actionPlans.ts b/src/utilities/access/actionPlans.ts new file mode 100644 index 00000000..93b880e3 --- /dev/null +++ b/src/utilities/access/actionPlans.ts @@ -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 => { + 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)) +} diff --git a/src/utilities/access/campaignUsers.ts b/src/utilities/access/campaignUsers.ts new file mode 100644 index 00000000..be4d36a0 --- /dev/null +++ b/src/utilities/access/campaignUsers.ts @@ -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 => { + 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 + 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) +} diff --git a/src/utilities/access/contacts.ts b/src/utilities/access/contacts.ts new file mode 100644 index 00000000..026e7a6e --- /dev/null +++ b/src/utilities/access/contacts.ts @@ -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 => { + const currentUser = + isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user + + if (!isCampaignUser(currentUser)) return [] + + const context = req.context as Record + 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 + 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) diff --git a/src/utilities/access/demands.ts b/src/utilities/access/demands.ts new file mode 100644 index 00000000..773e7944 --- /dev/null +++ b/src/utilities/access/demands.ts @@ -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 => { + 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) diff --git a/src/utilities/access/elections.ts b/src/utilities/access/elections.ts new file mode 100644 index 00000000..a40a0ab6 --- /dev/null +++ b/src/utilities/access/elections.ts @@ -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) diff --git a/src/utilities/access/invites.ts b/src/utilities/access/invites.ts new file mode 100644 index 00000000..d2798fc2 --- /dev/null +++ b/src/utilities/access/invites.ts @@ -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) diff --git a/src/utilities/access/leaderships.ts b/src/utilities/access/leaderships.ts new file mode 100644 index 00000000..c8440d97 --- /dev/null +++ b/src/utilities/access/leaderships.ts @@ -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 => { + 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 + 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 + 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 +} diff --git a/src/utilities/access/municipalities.ts b/src/utilities/access/municipalities.ts new file mode 100644 index 00000000..bf895064 --- /dev/null +++ b/src/utilities/access/municipalities.ts @@ -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, + advisorID: number, + req?: CampaignTransactionRequest, +): Promise => { + 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, + userID: number, + req?: CampaignTransactionRequest, +): Promise => { + 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 => { + const currentUser = + isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user + if (!isCampaignUser(currentUser)) return null + + const context = req.context as Record + const cacheKey = `${OWN_LEADERSHIP_CONTEXT_KEY}:${currentUser.id}` + if (cacheKey in context) return context[cacheKey] as OwnLeadership + + const collections = req.payload.collections as Record + 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 => { + 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 + 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 + 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) diff --git a/src/utilities/access/municipalityUpdates.ts b/src/utilities/access/municipalityUpdates.ts new file mode 100644 index 00000000..19c0a356 --- /dev/null +++ b/src/utilities/access/municipalityUpdates.ts @@ -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) diff --git a/src/utilities/access/organizations.ts b/src/utilities/access/organizations.ts new file mode 100644 index 00000000..07e89104 --- /dev/null +++ b/src/utilities/access/organizations.ts @@ -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) diff --git a/src/utilities/access/shared.ts b/src/utilities/access/shared.ts new file mode 100644 index 00000000..f5a28c82 --- /dev/null +++ b/src/utilities/access/shared.ts @@ -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 + where: Record +}) => Promise<{ docs: Array> }> + +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 => { + if (!isCampaignUser(user)) return null + + const context = req.context as Record + 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 +} diff --git a/src/utilities/access/stateDeputies.ts b/src/utilities/access/stateDeputies.ts new file mode 100644 index 00000000..230c17e7 --- /dev/null +++ b/src/utilities/access/stateDeputies.ts @@ -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) diff --git a/src/utilities/access/supporters.ts b/src/utilities/access/supporters.ts new file mode 100644 index 00000000..d174091b --- /dev/null +++ b/src/utilities/access/supporters.ts @@ -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 => { + 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) diff --git a/src/utilities/access/votePledges.ts b/src/utilities/access/votePledges.ts new file mode 100644 index 00000000..be7adf56 --- /dev/null +++ b/src/utilities/access/votePledges.ts @@ -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 => { + 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) diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index ba2cf32c..a435cd67 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -1,1003 +1,130 @@ -import type { CampaignUser, User } from '@/payload-types' -import type { Access, FieldAccess, Payload, PayloadRequest, Where } from 'payload' - -import { relationshipId } from '@/utilities/relationship' - -type CampaignActor = CampaignUser | User | null | undefined -type MunicipalityID = number -type AccessibleMunicipalityIDs = MunicipalityID[] | null -type ContactID = number -type LeadershipID = number -type AccessibleLeadershipIDs = LeadershipID[] | null - -const ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY = 'campaignAccessibleMunicipalityIds' -const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds' -const ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY = 'campaignAccessibleLeadershipIds' -const OWN_LEADERSHIP_CONTEXT_KEY = 'campaignOwnLeadership' -const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess' -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 } - -type DynamicFind = (args: { - collection: string - depth: number - limit: number - overrideAccess: true - pagination: false - req?: CampaignTransactionRequest - select: Record - where: Record -}) => Promise<{ docs: Array> }> - -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. + * Public surface of campaign RBAC; implementations live in + * `src/utilities/access/*` by domain. Import sites keep importing from + * `@/utilities/campaignAccess` — add new rules to the matching domain module + * (or a new one) and re-export them here. */ -export const payloadAdminOnly: Access = ({ req }) => isPayloadAdmin(req.user) -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 => { - if (!isCampaignUser(user)) return null - - const context = req.context as Record - 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 -} - -// --------------------------------------------------------------------------- -// Campaign users -// --------------------------------------------------------------------------- - -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 => { - 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) - -// --------------------------------------------------------------------------- -// Municipalities (Praças) -// --------------------------------------------------------------------------- - -/** - * 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, - advisorID: number, - req?: CampaignTransactionRequest, -): Promise => { - 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, - userID: number, - req?: CampaignTransactionRequest, -): Promise => { - 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 => { - const currentUser = - isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user - if (!isCampaignUser(currentUser)) return null - - const context = req.context as Record - const cacheKey = `${OWN_LEADERSHIP_CONTEXT_KEY}:${currentUser.id}` - if (cacheKey in context) return context[cacheKey] as OwnLeadership - - const collections = req.payload.collections as Record - 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 => { - 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 - const cacheKey = `${ACCESSIBLE_PLAZA_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 - 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) - -// --------------------------------------------------------------------------- -// Contacts -// --------------------------------------------------------------------------- - -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) - -// --------------------------------------------------------------------------- -// Leaderships -// --------------------------------------------------------------------------- - -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 => { - 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 - 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 - 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 -} - -export const getAccessibleContactIds = async ( - req: PayloadRequest, - user: CampaignActor = req.user, -): Promise => { - const currentUser = - isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user - - if (!isCampaignUser(currentUser)) return [] - - const context = req.context as Record - 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 - 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 -} - -// --------------------------------------------------------------------------- -// Vote pledges (declared by staff, estimated by staff) -// --------------------------------------------------------------------------- - -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 => { - 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) - -// --------------------------------------------------------------------------- -// Municipality updates (immutable field reports) -// --------------------------------------------------------------------------- - -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) - -// --------------------------------------------------------------------------- -// Campaign invites -// --------------------------------------------------------------------------- - -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) - -// --------------------------------------------------------------------------- -// Supporters -// --------------------------------------------------------------------------- - -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 => { - 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) - -// --------------------------------------------------------------------------- -// Organizations -// --------------------------------------------------------------------------- - -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) - -// --------------------------------------------------------------------------- -// Campaign demands -// --------------------------------------------------------------------------- - -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 => { - 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) - -// --------------------------------------------------------------------------- -// Action plans -// --------------------------------------------------------------------------- - -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 => { - 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)) -} - -// --------------------------------------------------------------------------- -// Election data -// --------------------------------------------------------------------------- - -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) - -// --------------------------------------------------------------------------- -// 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 - 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) -} - -// --------------------------------------------------------------------------- -// State deputies (dobradinhas) -// --------------------------------------------------------------------------- - -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) +export type { CampaignTransactionRequest } from '@/utilities/access/shared' +export { + eligibleCampaignStaffWhere, + getFreshCampaignUser, + isCampaignCandidate, + isCampaignCoordinator, + isCampaignLeader, + isCampaignStaff, + isCampaignUnrestricted, + isPayloadAdmin, + payloadAdminOnly, +} from '@/utilities/access/shared' + +export { + canCreateCampaignUserPhone, + canManageCampaignStaffField, + canManageCampaignUserRole, + canManageCampaignUsers, + canReadCampaignStaffField, + canReadCampaignUserIdentity, + canReadCampaignUserPhone, + canReadCampaignUsers, + canSetCampaignSystemField, + canUpdateCampaignUser, + canUpdateCampaignUserAvatar, + canUpdateCampaignUserPhone, +} from '@/utilities/access/campaignUsers' + +export { + canAssignMunicipalityAdvisors, + canCreateMunicipality, + canDeleteMunicipality, + canManageMunicipalityAdvisors, + canReadMunicipality, + canUpdateMunicipality, + getAccessibleMunicipalityIds, + getAdvisorMunicipalityIds, + getEngagedLeaderMunicipalityIds, + getOwnEngagedLeadership, +} from '@/utilities/access/municipalities' + +export { + canManageContacts, + canReadContacts, + getAccessibleContactIds, +} from '@/utilities/access/contacts' + +export { + canCreateLeadership, + canDeleteLeadership, + canManageLeadership, + canReadLeadership, + canSetAdministrativeLeadershipField, + getAccessibleLeadershipIds, +} from '@/utilities/access/leaderships' + +export { + canCreateVotePledge, + canDeleteVotePledge, + canReadVotePledge, + canUpdateVotePledge, +} from '@/utilities/access/votePledges' + +export { + canCreateMunicipalityUpdate, + canMutateMunicipalityUpdate, + canReadMunicipalityUpdate, + canSetMunicipalityUpdateAuthor, +} from '@/utilities/access/municipalityUpdates' + +export { + canCreateCampaignInvite, + canMutateCampaignInvite, + canReadCampaignInvite, + canSetCampaignInviteSystemField, +} from '@/utilities/access/invites' + +export { + canCreateSupporter, + canDeleteSupporter, + canManageSupporter, + canReadSupporter, +} from '@/utilities/access/supporters' + +export { + canCreateOrganization, + canDeleteOrganization, + canManageOrganization, + canReadOrganization, +} from '@/utilities/access/organizations' + +export { + canCreateCampaignDemand, + canDeleteCampaignDemand, + canReadCampaignDemand, + canUpdateCampaignDemand, +} from '@/utilities/access/demands' + +export { + canCreateActionPlan, + canCreateActionPlanAdvisors, + canDeleteActionPlan, + canManageActionPlanAdvisors, + canReadActionPlan, + canSetActionPlanStatus, + canSetActionPlanSystemField, + canUpdateActionPlan, +} from '@/utilities/access/actionPlans' + +export type { ElectionDataReader } from '@/utilities/access/elections' +export { + assertCanReadElectionData, + canMutateElectionData, + canReadElectionData, + canReadElectionDataAsUser, +} from '@/utilities/access/elections' + +export { + canCreateStateDeputy, + canDeleteStateDeputy, + canManageStateDeputy, + canReadStateDeputy, +} from '@/utilities/access/stateDeputies' diff --git a/src/utilities/campaignEntityActions.ts b/src/utilities/campaignEntityActions.ts new file mode 100644 index 00000000..57b92b8d --- /dev/null +++ b/src/utilities/campaignEntityActions.ts @@ -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 ( + payload: Payload, + actor: CampaignUser, + policy: StaffEntityPolicy, + mutate: (currentActor: CampaignUser) => Promise, +): Promise => { + 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 + } +} diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts index 00f9892b..6a285631 100644 --- a/src/utilities/campaignFormActionError.ts +++ b/src/utilities/campaignFormActionError.ts @@ -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 = ( } 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 ({ + execute, + redirectTo, + safeMessages, + genericMessage, +}: { + execute: () => Promise + redirectTo: (result: Result) => string + safeMessages?: readonly string[] + genericMessage: string +}): Promise => { + let target: string + try { + target = redirectTo(await execute()) + } catch (error) { + return mapCampaignFormActionError({ error, safeMessages, genericMessage }) + } + + redirect(target) +} diff --git a/src/utilities/municipalityUi.ts b/src/utilities/municipalityUi.ts index 004d7253..17efa7fb 100644 --- a/src/utilities/municipalityUi.ts +++ b/src/utilities/municipalityUi.ts @@ -185,7 +185,7 @@ export const municipalityListCoverageLabels: Record { 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 => diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index ffb60bfe..314d212c 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -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 | undefined const nextAllocatedMunicipalityIndex = async ( @@ -177,7 +177,7 @@ const nextAllocatedMunicipalityIndex = async ( catalogSize: number, ): Promise => { 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 diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index f755d353..4046bf8a 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -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], diff --git a/tests/int/municipalityMapData.int.spec.ts b/tests/int/municipalityMapData.int.spec.ts index 3d67d3ec..5be4d7b0 100644 --- a/tests/int/municipalityMapData.int.spec.ts +++ b/tests/int/municipalityMapData.int.spec.ts @@ -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)