mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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.
382 lines
10 KiB
TypeScript
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 }),
|
|
],
|
|
}
|