teqo-backup/src/collections/VotePledge.ts
Francisco Ignácio Fontoura Solla 4806c417c8 Engineering Pass 3: consolidate patterns and lock them with guards.
Fold duplicated campanha access, loaders, list/filter/form shells, and scripts into shared modules; reorganize utilities by domain; stabilize e2e; and document prevention-first audit workflow so the same smells cannot creep back in.
2026-07-29 06:11:29 -03:00

267 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
type VoteEstimateScenarioFields,
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
} from '@/lib/voteEstimate'
import {
canCreateVotePledge,
canDeleteVotePledge,
canManageCampaignStaffField,
canReadCampaignStaffField,
canReadVotePledge,
canSetCampaignSystemField,
canUpdateVotePledge,
payloadAdminOnly,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,
} from '@/utilities/voteEstimateScenarioFields'
/**
* Vote pledge per leadership × municipality. The leader DECLARES how many votes they
* are bringing (`declaredVotes` — the only number they ever see); staff record
* their own ESTIMATE of the real value (`estimatedVotes` pessimistic/central/
* optimistic), which is never serialized to the leader (field access denies
* read). Municipality aggregates use `estimated[S] ?? declared` on staff surfaces only.
*/
const normalizeEstimateGroup = (
value: VoteEstimateScenarioFields | number | null | undefined,
): VoteEstimateScenarioFields | null | undefined => {
if (value === undefined) return undefined
if (value === null) return null
if (typeof value === 'number') {
return { central: value, pessimistic: null, optimistic: null }
}
return value
}
const estimateGroupChanged = (
next: VoteEstimateScenarioFields | null | undefined,
previous: VoteEstimateScenarioFields | null | undefined,
): boolean => {
if (next === undefined) return false
const normalizedNext = normalizeEstimateGroup(next) ?? {}
const normalizedPrevious = normalizeEstimateGroup(previous) ?? {}
return (
normalizedNext.pessimistic !== normalizedPrevious.pessimistic ||
normalizedNext.central !== normalizedPrevious.central ||
normalizedNext.optimistic !== normalizedPrevious.optimistic
)
}
const validatePledgeIntegrity: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
const leadershipID = relationshipId(data.leadership ?? originalDoc?.leadership)
const municipalityID = relationshipId(data.municipality ?? originalDoc?.municipality)
if (!leadershipID || !municipalityID) {
throw new APIError('Informe a liderança e o município do compromisso de votos.', 400)
}
if (operation === 'update') {
const previousLeadership = relationshipId(originalDoc?.leadership)
const previousMunicipality = relationshipId(originalDoc?.municipality)
if (
(data.leadership !== undefined && leadershipID !== previousLeadership) ||
(data.municipality !== undefined && municipalityID !== previousMunicipality)
) {
throw new APIError('A liderança e o município do compromisso não podem ser alterados.', 409)
}
}
const leadership = await req.payload.findByID({
collection: 'leadership',
id: leadershipID,
depth: 0,
overrideAccess: true,
req,
})
const linkedMunicipalityIDs = (
Array.isArray(leadership.municipalities) ? leadership.municipalities : []
)
.map(relationshipId)
.filter((id): id is number => id !== null)
if (!linkedMunicipalityIDs.includes(municipalityID)) {
throw new APIError(
'A liderança precisa estar vinculada ao município para declarar votos nele.',
409,
)
}
return data
}
const deriveVotePledgeAudit: CollectionBeforeChangeHook = ({ data, originalDoc, req }) => {
const actorID = req.user?.collection === 'campaignUser' ? req.user.id : null
if (data.declaredVotes !== undefined && data.declaredVotes !== originalDoc?.declaredVotes) {
data.declaredAt = new Date().toISOString()
if (actorID) data.declaredBy = actorID
}
const estimateChanged =
estimateGroupChanged(
data.estimatedVotes as VoteEstimateScenarioFields | undefined,
originalDoc?.estimatedVotes as VoteEstimateScenarioFields | null | undefined,
) ||
(data.estimateNote !== undefined && data.estimateNote !== originalDoc?.estimateNote)
if (estimateChanged) {
data.estimatedAt = new Date().toISOString()
if (actorID) data.estimatedBy = actorID
}
return data
}
const validateEstimateOrder: CollectionBeforeChangeHook = ({ data }) => {
const violation = getVoteEstimateOrderViolation(
data.estimatedVotes as VoteEstimateScenarioFields | null | undefined,
)
if (violation) {
throw new APIError(VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, 400)
}
return data
}
export const VotePledge: CollectionConfig = {
slug: 'votePledge',
labels: {
singular: 'Votos declarados',
plural: 'Votos declarados',
},
admin: {
group: 'Campanha',
useAsTitle: 'id',
defaultColumns: [
'leadership',
'municipality',
'declaredVotes',
'estimatedVotes.central',
'updatedAt',
],
},
access: {
create: canCreateVotePledge,
read: canReadVotePledge,
readVersions: payloadAdminOnly,
update: canUpdateVotePledge,
delete: canDeleteVotePledge,
},
versions: {
maxPerDoc: 0,
},
indexes: [
{
fields: ['leadership', 'municipality'],
unique: true,
},
],
hooks: {
beforeChange: [validatePledgeIntegrity, validateEstimateOrder, deriveVotePledgeAudit],
},
fields: [
{
name: 'leadership',
type: 'relationship',
relationTo: 'leadership',
label: 'Liderança',
required: true,
index: true,
},
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
required: true,
index: true,
},
{
name: 'declaredVotes',
type: 'number',
label: 'Votos declarados pela liderança',
required: true,
min: 0,
index: true,
},
{
name: 'declaredAt',
type: 'date',
label: 'Declarado em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
systemStampedActorField({
name: 'declaredBy',
label: 'Declarado por',
indexed: false,
setAccess: canSetCampaignSystemField,
}),
{
name: 'estimatedVotes',
type: 'group',
label: 'Votos estimados pelo assessor',
admin: {
description:
'Faixa interna pessimista / média / otimista. A liderança nunca vê estes números.',
},
access: voteEstimateScenarioGroupAccess,
fields: voteEstimateScenarioGroupFields(),
},
{
name: 'estimateNote',
type: 'textarea',
label: 'Justificativa da estimativa',
maxLength: 1000,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'estimatedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Estimado por',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
read: canReadCampaignStaffField,
update: canSetCampaignSystemField,
},
},
{
name: 'estimatedAt',
type: 'date',
label: 'Estimado em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
read: canReadCampaignStaffField,
update: canSetCampaignSystemField,
},
},
],
}