Preserve pledge history, type field signals, link plan demands, and record immutable ex-ante allocation decisions.
147 lines
3.6 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
],
|
|
}
|