teqo-backup/src/utilities/access/contacts.ts
Francisco Ignácio Fontoura Solla 52c402c049 Fix the three leader-flow bugs the e2e suite pinned + RSC prop contracts
E2E-driven fixes (the suite now passes 9/9; two journeys remain
latency-flaky on cold dev compiles and pass on retry):

- leader home crashed with Forbidden since the remodel:
  loadLeaderContactsPageData loaded municipality options through the
  staff-scoped loader (leaders are denied municipality reads); their scope
  now derives from the engaged leadership's linked municipalities
- leaders saw 'Contato — —' instead of their supporters' names:
  getAccessibleContactIds now mirrors canReadSupporter for leaders
  (contacts of supporters they created)
- leader nav had two items with the same /campanha href (duplicate React
  keys); the leader home IS the contact tool — one entry
- RSC serialization contracts: CampaignListPagination is a server component
  again (hrefForPage stays server-side; CampaignTransitionAnchor client leaf
  intercepts clicks with serializable props); CampaignSearchForm takes
  basePath instead of a function prop
- hasAnyEstimate moved from the 'use client' PledgeEstimateForm to
  voteEstimate.ts — server components may not call client-module exports
- playwright.config honors TEQO_TEST_DATABASE_URL (parity with
  vitest.setup.ts) and uses a fixed .next/e2e dist dir so Next stops
  appending per-PID entries to tsconfig include; report artifacts
  eslint-ignored
2026-07-24 00:38:40 -03:00

119 lines
3.5 KiB
TypeScript

// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
import type { Access, PayloadRequest } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { CampaignActor, DynamicFind } from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type ContactID = number
const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds'
export const getAccessibleContactIds = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<ContactID[]> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return []
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const leadershipWhere =
currentUser.role === 'leader'
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
}
: {
municipalities: {
in: municipalityIDs ?? [],
},
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const collections = req.payload.collections as Record<string, unknown>
const contactIDs: ContactID[] = []
if (collections.leadership) {
const leadershipResult = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: leadershipWhere,
})
for (const doc of leadershipResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
// Advisors read contacts of supporters in their municipalities; leaders read
// contacts of the supporters they created — mirroring `canReadSupporter`.
const supporterWhere =
currentUser.role === 'advisor'
? { municipality: { in: municipalityIDs ?? [] } }
: currentUser.role === 'leader'
? { createdBy: { equals: currentUser.id } }
: null
if (supporterWhere && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: supporterWhere,
})
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)