diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
index 3142cc6e..9460a666 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx
@@ -13,7 +13,7 @@ import { VoteIntentionControl } from '@/components/campaign/VoteIntentionControl
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { isCampaignCoordinator } from '@/utilities/campaignAccess'
+import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadSupporterDetailConsentData,
@@ -69,9 +69,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
Voltar para apoiadores
-
- {isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
-
+ {campaignRoleLabels[user.role]}
{supporter.name}
{supporter.city ?? 'Município não informado'}
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
index 4a43e2c4..1fdfc67e 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
@@ -34,7 +34,7 @@ const safeActionMessages = [
'Esta pessoa já está cadastrada como apoiador nesta Praça.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
'Somente a coordenação e a assessoria podem gerenciar apoiadores.',
- 'Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.',
+ 'Somente o Coordenador Geral pode cadastrar apoiadores sem município.',
'Consentimento de cadastro de apoiador ainda não configurado.',
'Consentimento de intenção de voto ainda não configurado.',
] as const
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
index baeeeb9f..73785b44 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
@@ -7,7 +7,7 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/SupporterForm'
import { Button } from '@/components/ui/button'
-import { isCampaignCoordinator } from '@/utilities/campaignAccess'
+import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
@@ -26,9 +26,7 @@ export default async function NewSupporterPage() {
← Voltar para apoiadores
-
- {isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
-
+ {campaignRoleLabels[user.role]}
Novo apoiador
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx
index 737119f4..d3de2acc 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx
@@ -19,8 +19,9 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
-import { isCampaignCoordinator } from '@/utilities/campaignAccess'
+import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
+import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupportersPageData } from '@/utilities/supporterPageData'
import {
buildSupporterFiltersKey,
@@ -58,8 +59,8 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
Base nominal de apoio com intenção de voto e vínculo opcional a Praças.
- {isCampaignCoordinator(user)
- ? 'Coordenador Geral · todos os apoiadores'
+ {isCampaignUnrestricted(user)
+ ? `${campaignRoleLabels[user.role]} · todos os apoiadores`
: getSupporterScopeLabel(result.totalDocs)}
diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx
index 19c15e86..daf83c54 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx
@@ -13,7 +13,7 @@ import {
campaignDemandStatusLabels,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
-import { isCampaignStaff } from '@/utilities/campaignAccess'
+import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandDetail } from '@/utilities/campaignDemandData'
import {
@@ -51,12 +51,11 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
const { slug } = await params
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
+ if (!isCampaignStaff(user)) redirect('/campanha')
const demand = await loadDemandDetail(payload, user, slug)
if (!demand) notFound()
- const isStaffView = isCampaignStaff(user)
-
return (
@@ -105,82 +104,67 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
) : null}
- {isStaffView ? (
- <>
-
+
-
-
- Controle interno
-
-
- Custo:{' '}
- {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
-
- {demand.receipts.length ? (
-
- ) : (
- Nenhum comprovante anexado.
- )}
- {demand.statusHistory.length ? (
-
-
Histórico
-
- {demand.statusHistory.map((entry, index) => (
-
- {campaignDemandStatusLabels[entry.status]}
- {entry.authorName ? ` — ${entry.authorName}` : ''}
- {entry.createdAt
- ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
- : ''}
- {entry.note ? ` · ${entry.note}` : ''}
-
- ))}
-
-
- ) : null}
-
- >
- ) : (
-
-
- {demand.status === 'aberta'
- ? 'Sua demanda foi registrada e aguarda análise da assessoria.'
- : demand.status === 'em_analise' || demand.status === 'escalada'
- ? 'Sua demanda está em análise pela coordenação.'
- : 'Sua demanda foi decidida — veja a nota acima.'}
-
-
- )}
+
+
+ Controle interno
+
+
+ Custo: {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
+
+ {demand.receipts.length ? (
+
+ ) : (
+ Nenhum comprovante anexado.
+ )}
+ {demand.statusHistory.length ? (
+
+
Histórico
+
+ {demand.statusHistory.map((entry, index) => (
+
+ {campaignDemandStatusLabels[entry.status]}
+ {entry.authorName ? ` — ${entry.authorName}` : ''}
+ {entry.createdAt
+ ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
+ : ''}
+ {entry.note ? ` · ${entry.note}` : ''}
+
+ ))}
+
+
+ ) : null}
+
)
}
diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
index 3821e9c7..435ea8ba 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
@@ -7,6 +7,7 @@ import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { DemandForm } from '@/components/campaign/DemandForm'
import { Button } from '@/components/ui/button'
+import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
@@ -14,6 +15,7 @@ import { createDemandFormAction } from './formActions'
export default async function NewDemandPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
+ if (!isCampaignStaff(user)) redirect('/campanha')
const municipalityOptions = await loadMunicipalityOptions(payload, user)
diff --git a/src/app/(campaign)/campanha/(app)/demandas/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/page.tsx
index a7fc2d5c..efaca7a3 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/page.tsx
@@ -30,6 +30,7 @@ import {
campaignDemandStatuses,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
+import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandListPageData, parseDemandListParams } from '@/utilities/campaignDemandData'
@@ -54,6 +55,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
+ if (!isCampaignStaff(user)) redirect('/campanha')
const state = parseDemandListParams(rawSearchParams)
const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData(
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
index 223a989a..4a2f6447 100644
--- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
@@ -48,6 +48,7 @@ export default async function ActionPlanDetailPage({
])
if (!user) redirect('/campanha/login')
+ if (!isCampaignStaff(user)) redirect('/campanha')
if (!slug) notFound()
const activeTab = resolveActionPlanDetailTab(query)
diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/planos/page.tsx
index 57e25cfc..6bfa09a4 100644
--- a/src/app/(campaign)/campanha/(app)/planos/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/planos/page.tsx
@@ -40,6 +40,7 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
+ if (!isCampaignStaff(user)) redirect('/campanha')
const now = new Date()
const [{ result, state }, municipalityOptions] = await Promise.all([
diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts
index 1545ce43..d9d1e230 100644
--- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts
+++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts
@@ -3,14 +3,11 @@
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
-import {
- assertMunicipalityManagement,
- upsertContactByPhone,
-} from '@/app/(campaign)/campanha/actions/supporter'
+import { upsertContactByPhone } from '@/app/(campaign)/campanha/actions/supporter'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { leaderSupporterCreateSchema } from '@/lib/schemas/supporter'
import type { CampaignUser, Supporter } from '@/payload-types'
-import { isCampaignLeader } from '@/utilities/campaignAccess'
+import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import {
requireConsentByKey,
@@ -38,11 +35,15 @@ export type LeaderSupporterFormValues = {
municipality?: string
}
+const MUNICIPALITY_OUT_OF_SCOPE_MESSAGE =
+ 'Você só pode cadastrar contatos nos municípios da sua liderança.'
+
const safeActionMessages = [
'Esta pessoa já está cadastrada como apoiador neste município.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
'Somente lideranças podem cadastrar contatos por aqui.',
'Consentimento de cadastro de apoiador ainda não configurado.',
+ MUNICIPALITY_OUT_OF_SCOPE_MESSAGE,
] as const
const getLeaderSupporterFormError = (
@@ -82,7 +83,18 @@ export const createLeaderSupporterRecord = async (
payload,
async ({ req }) => {
const currentActor = await getFreshLeaderActor(payload, actor, req)
- await assertMunicipalityManagement(payload, currentActor, data.municipality, req)
+
+ // Leaders cannot read the municipality collection, so scope is asserted
+ // against their own engaged leadership's municipalities (mirroring the
+ // leader branch of the collection-level `canCreateSupporter`).
+ const accessibleMunicipalityIDs = await getEngagedLeaderMunicipalityIds(
+ payload,
+ currentActor.id,
+ req,
+ )
+ if (!accessibleMunicipalityIDs.includes(data.municipality)) {
+ throw new Error(MUNICIPALITY_OUT_OF_SCOPE_MESSAGE)
+ }
const registrationConsent = await requireConsentByKey(
payload,
diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts
index 4a8f58ee..b097c3e2 100644
--- a/src/app/(campaign)/campanha/actions/supporter.ts
+++ b/src/app/(campaign)/campanha/actions/supporter.ts
@@ -193,7 +193,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
if (data.municipality) {
await assertMunicipalityManagement(payload, currentActor, data.municipality, req)
} else if (currentActor.role !== 'coordinator') {
- throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.')
+ throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem município.')
}
const registrationConsent = await requireSupporterRegistrationConsent(
diff --git a/src/collections/Consent.ts b/src/collections/Consent.ts
index 69df3b24..6389ce35 100644
--- a/src/collections/Consent.ts
+++ b/src/collections/Consent.ts
@@ -1,3 +1,4 @@
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { CollectionConfig } from 'payload'
export const Consent: CollectionConfig = {
@@ -10,6 +11,15 @@ export const Consent: CollectionConfig = {
group: 'Contatos',
useAsTitle: 'text',
},
+ // Versioned legal texts referenced by signatures/subscriptions/supporters.
+ // Server flows resolve them via the Local API without a user; only admins
+ // may create or alter them.
+ access: {
+ create: payloadAdminOnly,
+ read: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
+ },
fields: [
{
name: 'key',
diff --git a/src/collections/Media.ts b/src/collections/Media.ts
index c02edab0..8bdb4ace 100644
--- a/src/collections/Media.ts
+++ b/src/collections/Media.ts
@@ -1,3 +1,4 @@
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
@@ -9,8 +10,13 @@ export const Media: CollectionConfig = {
admin: {
group: 'Coleções',
},
+ // Campaign flows (avatar, demand receipts) upload via server actions with an
+ // explicit overrideAccess: true after their own role checks.
access: {
read: () => true,
+ create: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
},
fields: [
{
diff --git a/src/collections/Petition.ts b/src/collections/Petition.ts
index 4723d539..5ebc81bf 100644
--- a/src/collections/Petition.ts
+++ b/src/collections/Petition.ts
@@ -1,4 +1,5 @@
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById } from '@/utilities/documents'
import type { CollectionConfig } from 'payload'
@@ -25,6 +26,10 @@ export const Petition: CollectionConfig = {
},
access: {
read: () => true,
+ readVersions: payloadAdminOnly,
+ create: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
},
hooks: {
beforeChange: [
diff --git a/src/collections/Post.ts b/src/collections/Post.ts
index b285fa9a..6bacef48 100644
--- a/src/collections/Post.ts
+++ b/src/collections/Post.ts
@@ -1,3 +1,4 @@
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/utilities/slug'
import type { CollectionConfig } from 'payload'
@@ -22,6 +23,10 @@ export const Post: CollectionConfig = {
},
access: {
read: () => true,
+ readVersions: payloadAdminOnly,
+ create: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
},
hooks: {
afterChange: [
diff --git a/src/collections/Signature.ts b/src/collections/Signature.ts
index 72864516..e5c89635 100644
--- a/src/collections/Signature.ts
+++ b/src/collections/Signature.ts
@@ -1,3 +1,4 @@
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { signatureContactToCSV, signaturePetitionToCSV } from '@/utilities/signatureExport'
import { CollectionConfig } from 'payload'
@@ -10,6 +11,14 @@ export const Signature: CollectionConfig = {
admin: {
group: 'Abaixo-assinados',
},
+ // Citizen PII. Public petition flows write via the Local API without a user
+ // (overrideAccess defaults to true), so admin-only access does not affect them.
+ access: {
+ create: payloadAdminOnly,
+ read: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
+ },
fields: [
{
type: 'relationship',
diff --git a/src/collections/Subscription.ts b/src/collections/Subscription.ts
index 3da3465e..d7ce4106 100644
--- a/src/collections/Subscription.ts
+++ b/src/collections/Subscription.ts
@@ -1,3 +1,4 @@
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { CollectionConfig } from 'payload'
export const Subscription: CollectionConfig = {
@@ -9,6 +10,14 @@ export const Subscription: CollectionConfig = {
admin: {
group: 'Contatos',
},
+ // Citizen PII. Public opt-in flows write via the Local API without a user
+ // (overrideAccess defaults to true), so admin-only access does not affect them.
+ access: {
+ create: payloadAdminOnly,
+ read: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
+ },
fields: [
{
type: 'relationship',
diff --git a/src/collections/Tag.ts b/src/collections/Tag.ts
index 06bb2be6..95a038f7 100644
--- a/src/collections/Tag.ts
+++ b/src/collections/Tag.ts
@@ -1,3 +1,4 @@
+import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/utilities/slug'
import type { CollectionConfig } from 'payload'
@@ -16,6 +17,9 @@ export const Tag: CollectionConfig = {
},
access: {
read: () => true,
+ create: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
},
hooks: {
afterChange: [
diff --git a/src/collections/Users.ts b/src/collections/Users.ts
index eb4f2b7a..2998534f 100644
--- a/src/collections/Users.ts
+++ b/src/collections/Users.ts
@@ -1,3 +1,4 @@
+import { isPayloadAdmin, payloadAdminOnly } from '@/utilities/campaignAccess'
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
@@ -11,6 +12,17 @@ export const Users: CollectionConfig = {
useAsTitle: 'email',
},
auth: true,
+ // Admin accounts are managed only by admin accounts. Without this, Payload's
+ // "any authenticated user" default would let a campaignUser JWT manage admins
+ // through /api/users — a privilege-escalation path.
+ access: {
+ admin: ({ req }) => isPayloadAdmin(req.user),
+ create: payloadAdminOnly,
+ read: payloadAdminOnly,
+ update: payloadAdminOnly,
+ delete: payloadAdminOnly,
+ unlock: payloadAdminOnly,
+ },
fields: [
// Email added by default
// Add more fields as needed
diff --git a/src/components/campaign/DemandWorkflowCard.tsx b/src/components/campaign/DemandWorkflowCard.tsx
index e627ffe6..9ab64098 100644
--- a/src/components/campaign/DemandWorkflowCard.tsx
+++ b/src/components/campaign/DemandWorkflowCard.tsx
@@ -24,7 +24,8 @@ type FormAction = (
type DemandWorkflowCardProps = {
demandID: number
status: CampaignDemandStatus
- isCoordinator: boolean
+ /** Coordinator or candidate — the roles allowed to decide escalated demands. */
+ canDecideEscalated: boolean
currentCost: number | null
transitionFormAction: FormAction
costFormAction: FormAction
@@ -38,7 +39,7 @@ const transitionVariant = (target: CampaignDemandStatus) =>
export const DemandWorkflowCard = ({
demandID,
status,
- isCoordinator,
+ canDecideEscalated,
currentCost,
transitionFormAction,
costFormAction,
@@ -53,7 +54,9 @@ export const DemandWorkflowCard = ({
const availableTransitions = campaignDemandTransitions[status].filter(
(target) =>
- isCoordinator || status !== 'escalada' || (target !== 'aprovada' && target !== 'rejeitada'),
+ canDecideEscalated ||
+ status !== 'escalada' ||
+ (target !== 'aprovada' && target !== 'rejeitada'),
)
return (
diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts
index 3aa25ad4..ba2cf32c 100644
--- a/src/utilities/campaignAccess.ts
+++ b/src/utilities/campaignAccess.ts
@@ -33,6 +33,14 @@ type DynamicFind = (args: {
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)
+
const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
user?.collection === 'campaignUser'
@@ -176,6 +184,37 @@ export const getAdvisorMunicipalityIds = async (
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
/**
diff --git a/src/utilities/campaignInviteCreation.ts b/src/utilities/campaignInviteCreation.ts
index d51c863a..a5ab73c0 100644
--- a/src/utilities/campaignInviteCreation.ts
+++ b/src/utilities/campaignInviteCreation.ts
@@ -7,6 +7,7 @@ import {
type CampaignInviteCreateInput,
} from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
+import { isCampaignStaff } from '@/utilities/campaignAccess'
import { reloadCampaignActor } from '@/utilities/campaignActionContext'
import {
buildCampaignInviteWhatsAppLink,
@@ -31,7 +32,9 @@ const getFreshInviteCreator = async (
req: PayloadTransactionRequest,
): Promise => {
const currentActor = await reloadCampaignActor(payload, actor, req)
- if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
+ // Staff only (coordinator, advisor, candidate) — matches the collection-level
+ // `canCreateCampaignInvite` scope; the leadership read below narrows advisors.
+ if (!isCampaignStaff(currentActor)) {
throw new Error('Somente a coordenação pode criar convites.')
}
return currentActor
diff --git a/src/utilities/supporterUi.ts b/src/utilities/supporterUi.ts
index 8f2633f6..aaa97024 100644
--- a/src/utilities/supporterUi.ts
+++ b/src/utilities/supporterUi.ts
@@ -120,5 +120,6 @@ export const resolveSupporterListUrl = (
export const getSupporterScopeLabel = (total: number): string =>
`${total} ${total === 1 ? 'apoiador nas suas Praças' : 'apoiadores nas suas Praças'}`
+/** Staff roles with supporter-area access — matches collection-level supporter access. */
export const canAccessSupporterArea = (role: CampaignUser['role']): boolean =>
- role === 'coordinator' || role === 'advisor'
+ role === 'coordinator' || role === 'advisor' || role === 'candidate'
diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts
index d4140e25..77d4d121 100644
--- a/tests/helpers/campaignFixtures.ts
+++ b/tests/helpers/campaignFixtures.ts
@@ -114,7 +114,17 @@ const emptyOwnedIDs = (): OwnedIDs => ({
consent: new Set(),
})
-const inviteConsentKey = 'lideranca-autopreenchimento'
+/**
+ * Consent rows shared across spec files (stable keys the app resolves at
+ * runtime). They are leased via `testDatabaseLease.ts`, never owned by a
+ * fixture instance — deleting them at cleanup would steal them from
+ * concurrently running spec files.
+ */
+const leasedConsentKeys = new Set([
+ 'lideranca-autopreenchimento',
+ 'apoiador-cadastro',
+ 'apoiador-intencao-voto',
+])
const defaultConsentText = (text: string): Consent['text'] => ({
root: {
@@ -237,8 +247,24 @@ const purgeMunicipalityResidue = async (
}),
])
+ // Residue invites reference residue leaderships (FK), so they must be
+ // found and deleted before the leaderships — otherwise the leadership
+ // delete aborts and the residue poisons every later claim of this municipality.
+ const leadershipIDs = leaderships.docs.map((doc) => doc.id)
+ const invites =
+ leadershipIDs.length > 0
+ ? await payload.find({
+ collection: 'campaignInvite',
+ where: { leadership: { in: leadershipIDs } },
+ depth: 0,
+ pagination: false,
+ select: {},
+ })
+ : { docs: [] }
+
const deletions: Array<{
collection:
+ | 'campaignInvite'
| 'votePledge'
| 'municipalityUpdate'
| 'campaignDemand'
@@ -247,11 +273,12 @@ const purgeMunicipalityResidue = async (
| 'actionPlan'
ids: number[]
}> = [
+ { collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) },
{ collection: 'votePledge', ids: pledges.docs.map((doc) => doc.id) },
{ collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) },
{ collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) },
{ collection: 'actionPlan', ids: plans.docs.map((doc) => doc.id) },
- { collection: 'leadership', ids: leaderships.docs.map((doc) => doc.id) },
+ { collection: 'leadership', ids: leadershipIDs },
{ collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) },
]
for (const { collection, ids } of deletions) {
@@ -283,13 +310,14 @@ export class CampaignFixtures {
const trackedCreate = async (args: Parameters[0]) => {
const document = await rootPayload.create(args as never)
const collection = args.collection
- const isLeasedInviteConsent =
+ const isLeasedConsent =
collection === 'consent' &&
typeof args.data === 'object' &&
args.data !== null &&
'key' in args.data &&
- args.data.key === inviteConsentKey
- if (collection in this.owned && typeof document.id === 'number' && !isLeasedInviteConsent) {
+ typeof args.data.key === 'string' &&
+ leasedConsentKeys.has(args.data.key)
+ if (collection in this.owned && typeof document.id === 'number' && !isLeasedConsent) {
this.own(collection as CampaignCollection, document.id)
}
return document
@@ -519,6 +547,13 @@ export class CampaignFixtures {
return user
}
+ /**
+ * Creates an OWNED consent row (deleted at cleanup) — even for a leased
+ * shared key, since calling this helper is an explicit ownership request.
+ * For the shared stable keys, prefer `ensureLeasedConsent` /
+ * `withLeasedConsent` (testDatabaseLease.ts) so parallel spec files are not
+ * robbed of the row at cleanup.
+ */
async createConsent(input: ConsentInput = {}): Promise {
const marker = this.value('consent')
const consent = await this.rootPayload.create({
diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts
index 8fe7d017..1026c575 100644
--- a/tests/helpers/testDatabaseLease.ts
+++ b/tests/helpers/testDatabaseLease.ts
@@ -51,7 +51,15 @@ type ConsentSnapshot = {
}
export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent'
+export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent'
+export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent'
const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento'
+
+/** A consent row shared across spec files, protected by an advisory-lock lease. */
+export type LeasedConsentFixture = {
+ consentKey: string
+ leaseKey: string
+}
const fixtureConsentText: Consent['text'] = {
root: {
type: 'root',
@@ -88,11 +96,8 @@ const rowsFrom = (result: unknown): Row[] => {
const combineErrors = (primary: unknown, cleanup: unknown, message: string): AggregateError =>
new AggregateError([primary, cleanup], message)
-const addFailure = (
- primary: unknown | undefined,
- secondary: unknown,
- message: string,
-): unknown => (primary === undefined ? secondary : combineErrors(primary, secondary, message))
+const addFailure = (primary: unknown | undefined, secondary: unknown, message: string): unknown =>
+ primary === undefined ? secondary : combineErrors(primary, secondary, message)
const rollbackWithFault = async (
lease: InternalTestDatabaseLease,
@@ -236,19 +241,14 @@ export const startTestDatabaseLeaseAcquisition = (
resolveBackendPID = resolve
rejectBackendPID = reject
})
- const acquisition = beginTestDatabaseLease(
- payload,
- leaseKey,
- mode,
- async ({ transactionID }) => {
- try {
- resolveBackendPID(await getTestTransactionBackendPID(payload, transactionID))
- } catch (error) {
- rejectBackendPID(error)
- throw error
- }
- },
- )
+ const acquisition = beginTestDatabaseLease(payload, leaseKey, mode, async ({ transactionID }) => {
+ try {
+ resolveBackendPID(await getTestTransactionBackendPID(payload, transactionID))
+ } catch (error) {
+ rejectBackendPID(error)
+ throw error
+ }
+ })
return { acquisition, backendPID }
}
@@ -302,9 +302,7 @@ export const waitForAdvisoryLockWaiter = async (
const waiting = rowsFrom(result)[0]
if (waiting) return waiting
}
- throw new Error(
- `Timed out waiting for backend ${waiterPID} on the exact ${mode} advisory lock.`,
- )
+ throw new Error(`Timed out waiting for backend ${waiterPID} on the exact ${mode} advisory lock.`)
}
export const acquireTestDatabaseLease = async (
@@ -373,11 +371,14 @@ export const withSharedTestDatabaseLease = async (
}
}
-export const ensureInviteConsent = async (payload: Payload): Promise =>
- withTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, async () => {
+export const ensureLeasedConsent = async (
+ payload: Payload,
+ { consentKey, leaseKey }: LeasedConsentFixture,
+): Promise =>
+ withTestDatabaseLease(payload, leaseKey, async () => {
const existing = await payload.find({
collection: 'consent',
- where: { key: { equals: CAMPAIGN_INVITE_CONSENT_KEY } },
+ where: { key: { equals: consentKey } },
limit: 1,
depth: 0,
})
@@ -386,27 +387,28 @@ export const ensureInviteConsent = async (payload: Payload): Promise =>
return payload.create({
collection: 'consent',
data: {
- key: CAMPAIGN_INVITE_CONSENT_KEY,
+ key: consentKey,
text: fixtureConsentText,
},
depth: 0,
})
})
-export const withInviteConsent = async (
+export const withLeasedConsent = async (
payload: Payload,
+ { consentKey, leaseKey }: LeasedConsentFixture,
operation: (consent: Consent) => Promise,
): Promise => {
for (;;) {
- await ensureInviteConsent(payload)
- const lease = await beginTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, 'shared')
+ await ensureLeasedConsent(payload, { consentKey, leaseKey })
+ const lease = await beginTestDatabaseLease(payload, leaseKey, 'shared')
let failure: unknown
let result: Result | undefined
let retry = false
try {
const configured = await payload.find({
collection: 'consent',
- where: { key: { equals: CAMPAIGN_INVITE_CONSENT_KEY } },
+ where: { key: { equals: consentKey } },
limit: 1,
depth: 0,
})
@@ -433,6 +435,22 @@ export const withInviteConsent = async (
}
}
+export const ensureInviteConsent = async (payload: Payload): Promise =>
+ ensureLeasedConsent(payload, {
+ consentKey: CAMPAIGN_INVITE_CONSENT_KEY,
+ leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
+ })
+
+export const withInviteConsent = async (
+ payload: Payload,
+ operation: (consent: Consent) => Promise,
+): Promise =>
+ withLeasedConsent(
+ payload,
+ { consentKey: CAMPAIGN_INVITE_CONSENT_KEY, leaseKey: CAMPAIGN_INVITE_CONSENT_LEASE_KEY },
+ operation,
+ )
+
export const withMissingInviteConsentFixture = async (
payload: Payload,
operation: () => Promise,
@@ -453,9 +471,7 @@ export const withMissingInviteConsentFixture = async (
LIMIT 1
`),
)[0]
- await setupLease.transaction.execute(
- sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`,
- )
+ await setupLease.transaction.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`)
} catch (error) {
setupError = error
}
@@ -509,9 +525,7 @@ export const withMissingInviteConsentFixture = async (
}
if (restoreLease) {
try {
- await restoreLease.transaction.execute(
- sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`,
- )
+ await restoreLease.transaction.execute(sql`DELETE FROM "consent" WHERE "key" = ${consentKey}`)
if (snapshot) {
await restoreLease.transaction.execute(sql`
INSERT INTO "consent" ("id", "key", "text", "updated_at", "created_at")
diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts
index f7df966a..5b977974 100644
--- a/tests/int/campaignSupporter.int.spec.ts
+++ b/tests/int/campaignSupporter.int.spec.ts
@@ -23,6 +23,12 @@ import {
import { buildSupporterListWhere } from '@/utilities/supporterUi'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
+import {
+ ensureLeasedConsent,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
+ withMissingInviteConsentFixture,
+} from '../helpers/testDatabaseLease'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@@ -32,24 +38,23 @@ const campaignFixtures = installCampaignFixtures({
},
})
-const ensureConsentByKey = async (fixtures: ReturnType, key: string) => {
- const existing = await payload.find({
- collection: 'consent',
- where: { key: { equals: key } },
- depth: 0,
- limit: 1,
- pagination: false,
- })
- if (existing.docs[0]) {
- fixtures.own('consent', existing.docs[0].id)
- return existing.docs[0]
- }
- return fixtures.createConsent({ key })
-}
+/**
+ * Shared consent rows (stable keys) are leased fixtures: ensured under an
+ * advisory-lock lease and never owned/deleted, so parallel spec files can rely
+ * on them (see `leasedConsentKeys` in campaignFixtures.ts).
+ */
+const ensureConsentByKey = (key: string, leaseKey: string) =>
+ ensureLeasedConsent(payload, { consentKey: key, leaseKey })
-const ensureSupporterConsents = async (fixtures: ReturnType) => {
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
- const voteIntention = await ensureConsentByKey(fixtures, SUPPORTER_VOTE_INTENTION_CONSENT_KEY)
+const ensureSupporterConsents = async () => {
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
+ const voteIntention = await ensureConsentByKey(
+ SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
+ SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
+ )
return { registration, voteIntention }
}
@@ -87,28 +92,27 @@ describe('campaign supporter domain', () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
- const existing = await payload.find({
- collection: 'consent',
- where: { key: { equals: SUPPORTER_REGISTRATION_CONSENT_KEY } },
- depth: 0,
- limit: 1,
- })
- if (existing.docs[0]) {
- await payload.delete({ collection: 'consent', id: existing.docs[0].id })
- }
-
- await expect(
- createSupporterRecord(payload, coordinator, {
- name: fixtures.value('Apoiador'),
- phone: fixtures.phone(),
- consentAccepted: true,
- }),
- ).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/)
+ await withMissingInviteConsentFixture(
+ payload,
+ async () => {
+ await expect(
+ createSupporterRecord(payload, coordinator, {
+ name: fixtures.value('Apoiador'),
+ phone: fixtures.phone(),
+ consentAccepted: true,
+ }),
+ ).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/)
+ },
+ {
+ consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
+ leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ },
+ )
})
it('creates a supporter with contact upsert and blocks leadership coexistence', async () => {
const fixtures = campaignFixtures()
- await ensureSupporterConsents(fixtures)
+ await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const phone = fixtures.phone()
@@ -144,7 +148,7 @@ describe('campaign supporter domain', () => {
it('scopes advisor create to administered municipalities and blocks municipality-less create', async () => {
const fixtures = campaignFixtures()
- await ensureSupporterConsents(fixtures)
+ await ensureSupporterConsents()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
const otherMunicipality = await fixtures.getMunicipality()
@@ -180,7 +184,10 @@ describe('campaign supporter domain', () => {
it('requires highlighted vote-intention consent before setting intention', async () => {
const fixtures = campaignFixtures()
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
const coordinator = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const supporter = await fixtures.createSupporter({
@@ -212,7 +219,10 @@ describe('campaign supporter domain', () => {
}),
).rejects.toThrow(/Consentimento de intenção de voto ainda não configurado/)
- await ensureConsentByKey(fixtures, SUPPORTER_VOTE_INTENTION_CONSENT_KEY)
+ await ensureConsentByKey(
+ SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
+ SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
+ )
const updated = await setSupporterVoteIntentionRecord(payload, coordinator, {
id: supporter.id,
@@ -225,7 +235,7 @@ describe('campaign supporter domain', () => {
it('previews and confirms CSV import for the coordinator only', async () => {
const fixtures = campaignFixtures()
- await ensureSupporterConsents(fixtures)
+ await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const phoneOk = fixtures.phone()
@@ -292,7 +302,7 @@ describe('campaign supporter domain', () => {
it('rejects a tampered import token and a token issued to another actor', async () => {
const fixtures = campaignFixtures()
- await ensureSupporterConsents(fixtures)
+ await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const otherCoordinator = await fixtures.createCampaignUser('coordinator')
const phoneOk = fixtures.phone()
@@ -343,7 +353,7 @@ describe('campaign supporter domain', () => {
it('removes supporter and anonymizes contact when no other joins remain', async () => {
const fixtures = campaignFixtures()
- await ensureSupporterConsents(fixtures)
+ await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const phone = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
@@ -370,7 +380,10 @@ describe('campaign supporter domain', () => {
it('aggregates supporter overview KPIs in a single SQL query for the coordinator', async () => {
const fixtures = campaignFixtures()
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
const coordinator = await fixtures.createCampaignUser('coordinator')
const intentions: Array<{
@@ -424,7 +437,10 @@ describe('campaign supporter domain', () => {
it('scopes the overview aggregate to administered municipalities for an advisor and applies filters', async () => {
const fixtures = campaignFixtures()
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
const advisor = await fixtures.createCampaignUser('advisor')
const assigned = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality()
@@ -471,7 +487,10 @@ describe('campaign supporter domain', () => {
it('ignores single-character search queries in list and overview filters', async () => {
const fixtures = campaignFixtures()
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
const coordinator = await fixtures.createCampaignUser('coordinator')
const uniqueName = fixtures.value('ApoiadorUnico')
@@ -509,7 +528,10 @@ describe('campaign supporter domain', () => {
it('keeps list and overview totals aligned for multi-character search', async () => {
const fixtures = campaignFixtures()
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
const coordinator = await fixtures.createCampaignUser('coordinator')
const uniqueName = fixtures.value('ApoiadorBusca')
@@ -547,11 +569,14 @@ describe('campaign supporter domain', () => {
it('loads supporters page data scoped to the administered municipalities of an advisor', async () => {
const fixtures = campaignFixtures()
- await ensureSupporterConsents(fixtures)
+ await ensureSupporterConsents()
const advisor = await fixtures.createCampaignUser('advisor')
const assigned = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(assigned, [advisor])
- const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
+ const registration = await ensureConsentByKey(
+ SUPPORTER_REGISTRATION_CONSENT_KEY,
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ )
const contact = await fixtures.createContact()
await fixtures.createSupporter({
diff --git a/tests/int/campaignUserPhone.int.spec.ts b/tests/int/campaignUserPhone.int.spec.ts
index 5f4025cc..e30b525a 100644
--- a/tests/int/campaignUserPhone.int.spec.ts
+++ b/tests/int/campaignUserPhone.int.spec.ts
@@ -62,7 +62,7 @@ describe('campaign user contact phone', () => {
})
})
- it('denies leaders from reading advisor contact phones', async () => {
+ it('omits advisor contact phones from leader reads (lockdown)', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisorPhone = fixtures.phone()
@@ -76,16 +76,19 @@ describe('campaign user contact phone', () => {
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
await createEngagedPhoneAccessGraph(fixtures, coordinator, leader, municipality.id)
- await expect(
- payload.findByID({
- collection: 'campaignUser',
- id: advisor.id,
- depth: 0,
- select: { name: true, phone: true },
- user: leader,
- overrideAccess: false,
- }),
- ).rejects.toThrow(/permissão/i)
+ // Leaders are lockdown accounts: even for advisors of their own linked
+ // municipality, the phone field must be withheld.
+ const advisorRead = await payload.findByID({
+ collection: 'campaignUser',
+ id: advisor.id,
+ depth: 0,
+ select: { name: true, phone: true },
+ user: leader,
+ overrideAccess: false,
+ })
+
+ expect(advisorRead.phone).toBeUndefined()
+ expect(JSON.stringify(advisorRead)).not.toContain(advisorPhone)
})
})
@@ -154,8 +157,11 @@ describe('campaign user contact phone', () => {
})
const byId = new Map(result.docs.map((doc) => [doc.id, doc]))
- expect(byId.get(advisor.id)?.phone).toBe(advisorPhone)
+ // Leaders are lockdown accounts: no staff phone is visible, linked
+ // municipality or not.
+ expect(byId.get(advisor.id)?.phone).toBeUndefined()
expect(byId.get(foreignAdvisor.id)?.phone).toBeUndefined()
+ expect(JSON.stringify(result.docs)).not.toContain(advisorPhone)
expect(JSON.stringify(result.docs)).not.toContain(foreignPhone)
})
})
diff --git a/tests/int/collectionAccessLockdown.int.spec.ts b/tests/int/collectionAccessLockdown.int.spec.ts
new file mode 100644
index 00000000..2a49f31f
--- /dev/null
+++ b/tests/int/collectionAccessLockdown.int.spec.ts
@@ -0,0 +1,207 @@
+// @vitest-environment node
+
+import { getPayload, type Payload } from 'payload'
+import { beforeAll, describe, expect, it } from 'vitest'
+
+import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/leaderSupporter'
+import config from '@/payload.config'
+import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent'
+
+import { installCampaignFixtures } from '../helpers/campaignFixtures'
+import {
+ SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ withLeasedConsent,
+} from '../helpers/testDatabaseLease'
+
+let payload: Payload
+const campaignFixtures = installCampaignFixtures({
+ getPayload: () => payload,
+ setPayload: (nextPayload) => {
+ payload = nextPayload
+ },
+})
+
+/** Runs `operation` with the shared supporter-registration consent guaranteed present. */
+const withSupporterRegistrationConsent = (
+ operation: () => Promise,
+): Promise =>
+ withLeasedConsent(
+ payload,
+ {
+ consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
+ leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
+ },
+ operation,
+ )
+
+/**
+ * Collections without explicit `access` fall back to Payload's "any
+ * authenticated user" default — which includes campaign users presenting the
+ * `campaign-token` JWT against `/api/*`. These tests pin the lockdown added to
+ * the CMS/PII collections: campaign users (even the coordinator) must be
+ * denied, admins keep working, and the public keeps read-only access where
+ * intended.
+ */
+describe('CMS/PII collection access lockdown', () => {
+ beforeAll(async () => {
+ payload = await getPayload({ config: await config })
+ })
+
+ it('denies campaign users any access to admin accounts (users)', async () => {
+ const coordinator = await campaignFixtures().createCampaignUser('coordinator')
+
+ await expect(
+ payload.count({ collection: 'users', where: {}, user: coordinator, overrideAccess: false }),
+ ).rejects.toThrow(/permissão|not allowed/i)
+
+ await expect(
+ payload.create({
+ collection: 'users',
+ data: { email: 'escalated@example.com', password: 'owned-by-attacker' },
+ user: coordinator,
+ overrideAccess: false,
+ }),
+ ).rejects.toThrow(/permissão|not allowed/i)
+ })
+
+ it('lets admin users keep managing the users collection', async () => {
+ const fixtures = campaignFixtures()
+ const admin = await fixtures.createAdminUser()
+
+ const result = await payload.count({
+ collection: 'users',
+ where: { id: { equals: admin.id } },
+ user: admin,
+ overrideAccess: false,
+ })
+ expect(result.totalDocs).toBe(1)
+ })
+
+ it.each(['signature', 'subscription', 'consent'] as const)(
+ 'denies campaign users and anonymous requests access to %s (PII/legal)',
+ async (collection) => {
+ const coordinator = await campaignFixtures().createCampaignUser('coordinator')
+
+ await expect(
+ payload.count({ collection, where: {}, user: coordinator, overrideAccess: false }),
+ ).rejects.toThrow(/permissão|not allowed/i)
+
+ await expect(payload.count({ collection, where: {}, overrideAccess: false })).rejects.toThrow(
+ /permissão|not allowed/i,
+ )
+ },
+ )
+
+ it('lets admin users keep reading signatures and subscriptions', async () => {
+ const fixtures = campaignFixtures()
+ const admin = await fixtures.createAdminUser()
+
+ await expect(
+ payload.count({ collection: 'signature', where: {}, user: admin, overrideAccess: false }),
+ ).resolves.toMatchObject({ totalDocs: expect.any(Number) })
+ await expect(
+ payload.count({ collection: 'subscription', where: {}, user: admin, overrideAccess: false }),
+ ).resolves.toMatchObject({ totalDocs: expect.any(Number) })
+ })
+
+ it.each(['post', 'tag', 'petition', 'media'] as const)(
+ 'denies campaign users create access to public-content collection %s',
+ async (collection) => {
+ const coordinator = await campaignFixtures().createCampaignUser('coordinator')
+
+ await expect(
+ payload.create({
+ collection,
+ data: {},
+ user: coordinator,
+ overrideAccess: false,
+ } as never),
+ ).rejects.toThrow(/permissão|not allowed/i)
+ },
+ )
+
+ it.each(['post', 'tag', 'petition', 'media'] as const)(
+ 'keeps anonymous read access to public-content collection %s',
+ async (collection) => {
+ await expect(
+ payload.count({ collection, where: {}, overrideAccess: false }),
+ ).resolves.toMatchObject({ totalDocs: expect.any(Number) })
+ },
+ )
+})
+
+describe('leader supporter creation scope', () => {
+ beforeAll(async () => {
+ payload = await getPayload({ config: await config })
+ })
+
+ it('lets an engaged leader register a supporter in a linked municipality', async () => {
+ const fixtures = campaignFixtures()
+ const leaderAccount = await fixtures.createCampaignUser('leader')
+ const leaderContact = await fixtures.createContact()
+ const linked = await fixtures.getMunicipality()
+ await fixtures.createLeadership({
+ contact: leaderContact.id,
+ municipalities: [linked.id],
+ user: leaderAccount.id,
+ supportStatus: 'engajado',
+ })
+
+ const supporter = await withSupporterRegistrationConsent(() =>
+ createLeaderSupporterRecord(payload, leaderAccount, {
+ name: fixtures.value('Apoiador'),
+ phone: fixtures.phone(),
+ city: 'Salvador',
+ municipality: linked.id,
+ consentAccepted: true,
+ }),
+ )
+ fixtures.own('supporter', supporter.id)
+
+ expect(supporter.source).toBe('lideranca')
+ expect(fixtures.id(supporter.createdBy as never)).toBe(leaderAccount.id)
+ expect(fixtures.id(supporter.municipality as never)).toBe(linked.id)
+ })
+
+ it('rejects a supporter outside the leadership municipalities', async () => {
+ const fixtures = campaignFixtures()
+ const leaderAccount = await fixtures.createCampaignUser('leader')
+ const leaderContact = await fixtures.createContact()
+ const linked = await fixtures.getMunicipality()
+ const outside = await fixtures.getMunicipality()
+ await fixtures.createLeadership({
+ contact: leaderContact.id,
+ municipalities: [linked.id],
+ user: leaderAccount.id,
+ supportStatus: 'engajado',
+ })
+
+ await expect(
+ withSupporterRegistrationConsent(() =>
+ createLeaderSupporterRecord(payload, leaderAccount, {
+ name: fixtures.value('Apoiador'),
+ phone: fixtures.phone(),
+ city: 'Salvador',
+ municipality: outside.id,
+ consentAccepted: true,
+ }),
+ ),
+ ).rejects.toThrow('Você só pode cadastrar contatos nos municípios da sua liderança.')
+ })
+
+ it('rejects staff accounts on the leader-only contact tool', async () => {
+ const fixtures = campaignFixtures()
+ const advisor = await fixtures.createCampaignUser('advisor')
+ const municipality = await fixtures.getMunicipality()
+
+ await expect(
+ createLeaderSupporterRecord(payload, advisor, {
+ name: fixtures.value('Apoiador'),
+ phone: fixtures.phone(),
+ city: 'Salvador',
+ municipality: municipality.id,
+ consentAccepted: true,
+ }),
+ ).rejects.toThrow('Somente lideranças podem cadastrar contatos por aqui.')
+ })
+})