teqo-backup/src/collections/CampaignDemand.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

382 lines
10 KiB
TypeScript

import type {
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE,
CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE,
campaignDemandKindLabels,
campaignDemandKinds,
campaignDemandStatusLabels,
campaignDemandStatuses,
campaignDemandTransitions,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
import {
canCreateCampaignDemand,
canDeleteCampaignDemand,
canManageCampaignStaffField,
canReadCampaignDemand,
canReadCampaignStaffField,
canSetCampaignSystemField,
canUpdateCampaignDemand,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
const DEMAND_KIND_OPTIONS = campaignDemandKinds.map((value) => ({
value,
label: campaignDemandKindLabels[value],
}))
const DEMAND_STATUS_OPTIONS = campaignDemandStatuses.map((value) => ({
value,
label: campaignDemandStatusLabels[value],
}))
const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => {
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
const slug = slugify(title)
if (!slug) {
throw new APIError('Informe um título com letras ou números.', 400)
}
data.title = title
data.slug = operation === 'create' ? slug : originalDoc?.slug
return data
}
const isDemandStatus = (value: unknown): value is CampaignDemandStatus =>
typeof value === 'string' && (campaignDemandStatuses as readonly string[]).includes(value)
/**
* Workflow + authorship rules (staff-only creation):
* - status moves follow campaignDemandTransitions; decisions on "escalada"
* demands are coordinator/candidate-only;
* - every status move appends a server-derived statusHistory entry.
*/
const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
const actor = req.user?.collection === 'campaignUser' ? req.user : null
if (operation === 'create') {
if (actor) data.createdBy = actor.id
const initialStatus = isDemandStatus(data.status) ? data.status : 'aberta'
data.status = initialStatus
data.statusHistory = [
{
status: initialStatus,
note: trimmedText(data.decisionNote) || null,
author: actor?.id ?? null,
createdAt: new Date().toISOString(),
},
]
return data
}
const previousStatus: CampaignDemandStatus = isDemandStatus(originalDoc?.status)
? originalDoc.status
: 'aberta'
const rawNextStatus: unknown = data.status === undefined ? previousStatus : data.status
if (!isDemandStatus(rawNextStatus)) {
throw new APIError(CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE, 400)
}
const nextStatus: CampaignDemandStatus = rawNextStatus
if (nextStatus !== previousStatus) {
const allowed = campaignDemandTransitions[previousStatus] ?? []
if (!allowed.includes(nextStatus)) {
throw new APIError(
`Transição de status inválida: ${campaignDemandStatusLabels[previousStatus]} → ${campaignDemandStatusLabels[nextStatus]}.`,
409,
)
}
if (
previousStatus === 'escalada' &&
(nextStatus === 'aprovada' || nextStatus === 'rejeitada') &&
actor &&
!isCampaignUnrestricted(actor)
) {
throw new APIError(CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE, 403)
}
const history = Array.isArray(originalDoc?.statusHistory) ? [...originalDoc.statusHistory] : []
history.push({
status: nextStatus,
note: trimmedText(data.decisionNote ?? originalDoc?.decisionNote) || null,
author: actor?.id ?? null,
createdAt: new Date().toISOString(),
})
data.statusHistory = history
if (nextStatus === 'aprovada' || nextStatus === 'rejeitada') {
data.decidedBy = actor?.id ?? null
data.decidedAt = new Date().toISOString()
}
} else if (data.statusHistory !== undefined) {
// History is append-only and server-derived.
data.statusHistory = originalDoc?.statusHistory ?? []
}
return data
}
const validateDemandActivityMunicipality: CollectionBeforeValidateHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (!data) return data
const municipalityID = relationshipId(data.municipality ?? originalDoc?.municipality)
const activityID = relationshipId(data.activity ?? originalDoc?.activity)
if (!activityID) return data
if (!municipalityID) throw new APIError('Município da demanda inválido.', 400)
if (
operation === 'update' &&
municipalityID === relationshipId(originalDoc?.municipality) &&
activityID === relationshipId(originalDoc?.activity)
) {
return data
}
const requestContext = req.context as Record<string, unknown>
const cacheKey = `campaignDemand:activityMunicipality:${activityID}`
let activityMunicipalityID = requestContext[cacheKey]
if (typeof activityMunicipalityID !== 'number') {
const activity = await req.payload.findByID({
collection: 'activity',
id: activityID,
depth: 0,
select: { municipality: true },
overrideAccess: true,
req,
})
activityMunicipalityID = relationshipId(activity.municipality)
requestContext[cacheKey] = activityMunicipalityID
}
if (activityMunicipalityID !== municipalityID) {
throw new APIError('A demanda e a atividade devem pertencer ao mesmo município.', 409)
}
return data
}
export const CampaignDemand: CollectionConfig = {
slug: 'campaignDemand',
labels: {
singular: 'Demanda',
plural: 'Demandas',
},
admin: {
group: 'Campanha',
useAsTitle: 'title',
defaultColumns: ['title', 'kind', 'municipality', 'status', 'updatedAt'],
description:
'Necessidades operacionais da campanha. Custo e comprovantes são controle interno — não substituem a prestação de contas oficial (SPCE/TSE).',
},
access: {
create: canCreateCampaignDemand,
read: canReadCampaignDemand,
update: canUpdateCampaignDemand,
delete: canDeleteCampaignDemand,
},
hooks: {
beforeValidate: [setCanonicalDemandSlug, validateDemandActivityMunicipality],
beforeChange: [enforceDemandWorkflow],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
minLength: 2,
maxLength: 160,
index: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo',
required: true,
index: true,
options: [...DEMAND_KIND_OPTIONS],
},
{
name: 'description',
type: 'textarea',
label: 'Descrição',
maxLength: 4000,
},
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
required: true,
index: true,
},
{
name: 'activity',
type: 'relationship',
relationTo: 'activity',
label: 'Atividade',
index: true,
},
{
name: 'leadership',
type: 'relationship',
relationTo: 'leadership',
label: 'Liderança solicitante',
index: true,
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'aberta',
index: true,
access: {
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
options: [...DEMAND_STATUS_OPTIONS],
},
{
name: 'decisionNote',
type: 'textarea',
label: 'Nota da decisão',
maxLength: 2000,
access: {
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'decidedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Decidida por',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'decidedAt',
type: 'date',
label: 'Decidida em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'cost',
type: 'number',
label: 'Custo estimado (R$)',
min: 0,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description: 'Controle interno de gastos. Não substitui a prestação de contas oficial.',
},
},
{
name: 'receipts',
type: 'upload',
relationTo: 'media',
label: 'Comprovantes',
hasMany: true,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description:
'Documentos fiscais podem conter CPF/CNPJ — acesso restrito ao staff. Não substitui o SPCE.',
},
},
{
name: 'statusHistory',
type: 'array',
label: 'Histórico de status',
access: {
read: canReadCampaignStaffField,
update: canSetCampaignSystemField,
},
admin: {
readOnly: true,
},
fields: [
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
options: [...DEMAND_STATUS_OPTIONS],
},
{
name: 'note',
type: 'textarea',
label: 'Nota',
maxLength: 2000,
},
{
name: 'author',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Autor',
},
{
name: 'createdAt',
type: 'date',
label: 'Registrado em',
},
],
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}