teqo/src/utilities/access/contacts.ts
Francisco Solla 6251167db0
Some checks failed
CI (PR) / checks (pull_request) Successful in 12m27s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 41m22s
feat(C141): perfis de permissão por assessor — Visão × Edição configuráveis, enforcement fail-closed (Closes #105)
2026-08-19 20:02:56 -03:00

126 lines
4.2 KiB
TypeScript

// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
import type { Access, PayloadRequest } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { CampaignActor, DynamicFind } from '@/utilities/access/shared'
import {
advisorMunicipalityScopeWhere,
getFreshCampaignUser,
isCampaignUnrestricted,
isPayloadAdmin,
resolveAccessibleIds,
} from '@/utilities/access/shared'
type ContactID = number
const ACCESSIBLE_CONTACT_IDS_MEMO_KEY = 'campaignAccessibleContactIds'
const getAccessibleContactIds = (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<ContactID[] | null> =>
resolveAccessibleIds(req, user, ACCESSIBLE_CONTACT_IDS_MEMO_KEY, async (currentUser) => {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const leadershipWhere =
currentUser.role === 'leader'
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
}
: // C141 — Visão "Tudo" widens the leadership-derived part of the people
// list; the supporter-derived part below stays carteira (PII cap).
currentUser.role === 'advisor' && currentUser.visibility === 'tudo'
? {}
: advisorMunicipalityScopeWhere('municipalities', 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 already read every dobradinha. Include the normalized Contacts
// backing those rows without widening access to other Contacts.
if (currentUser.role === 'advisor' && collections.stateDeputy) {
// Intentional admin bypass: StateDeputy access already scopes the advisor's rows.
const stateDeputyResult = await find({
collection: 'stateDeputy',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: {},
})
for (const doc of stateDeputyResult.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'
? advisorMunicipalityScopeWhere('municipality', 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)
}
}
return [...new Set(contactIDs)]
})
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)
if (ids === null) return true
return {
id: {
in: ids,
},
}
}
export const canManageContacts: Access = ({ req }) => isPayloadAdmin(req.user)