teqo/src/collections/AllocationDecision.ts
Francisco Ignácio Fontoura Solla 63910014ad Implement C12 campaign foundation records
Preserve pledge history, type field signals, link plan demands, and record immutable ex-ante allocation decisions.
2026-07-24 19:27:15 -03:00

147 lines
3.6 KiB
TypeScript

import { Buffer } from 'node:buffer'
import type {
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import { APIError } from 'payload'
import {
canCreateAllocationDecision,
canMutateAllocationDecision,
canReadAllocationDecision,
canSetCampaignSystemField,
} from '@/utilities/campaignAccess'
const MAX_SERIALIZED_SNAPSHOT_BYTES = 16_000
const validateAllocationDecision: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
if (
nextData.outcome === 'descarta' &&
(typeof nextData.alternativeReading !== 'string' || !nextData.alternativeReading.trim())
) {
throw new APIError('Informe a leitura alternativa ao descartar a sugestão.', 400)
}
try {
const serializedSnapshot = JSON.stringify(nextData.snapshot)
if (
typeof nextData.snapshot !== 'object' ||
nextData.snapshot === null ||
Array.isArray(nextData.snapshot) ||
Buffer.byteLength(serializedSnapshot, 'utf8') > MAX_SERIALIZED_SNAPSHOT_BYTES
) {
throw new APIError('O recorte da decisão está grande demais.', 400)
}
} catch (error) {
if (error instanceof APIError) throw error
throw new APIError('O recorte da decisão é inválido.', 400)
}
return data
}
const deriveDecisionAuthor: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.decidedBy = req.user.id
}
return data
}
export const AllocationDecision: CollectionConfig = {
slug: 'allocationDecision',
labels: {
singular: 'Decisão de alocação',
plural: 'Decisões de alocação',
},
admin: {
group: 'Campanha',
hidden: true,
useAsTitle: 'patternId',
},
access: {
create: canCreateAllocationDecision,
read: canReadAllocationDecision,
update: canMutateAllocationDecision,
delete: canMutateAllocationDecision,
},
hooks: {
beforeValidate: [validateAllocationDecision],
beforeChange: [deriveDecisionAuthor],
},
fields: [
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
required: true,
index: true,
},
{
name: 'patternId',
type: 'text',
label: 'Padrão avaliado',
required: true,
maxLength: 120,
index: true,
},
{
name: 'outcome',
type: 'select',
label: 'Decisão',
required: true,
options: [
{ label: 'Aceita', value: 'aceita' },
{ label: 'Descarta', value: 'descarta' },
],
},
{
name: 'rationale',
type: 'textarea',
label: 'Justificativa',
required: true,
maxLength: 2000,
},
{
name: 'alternativeReading',
type: 'textarea',
label: 'Leitura alternativa',
maxLength: 2000,
admin: {
condition: (_, siblingData) => siblingData.outcome === 'descarta',
},
},
{
name: 'snapshot',
type: 'json',
label: 'Recorte da decisão',
required: true,
admin: {
description: 'Somente os números e classificações usados no momento da decisão.',
},
},
{
name: 'decidedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Decidido por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
],
}