teqo-backup/src/collections/Supporter.ts
Francisco Solla e71b062650 feat(C139): schemas e actions de contato — criação staff e edição por célula
Fase 3 (server) do plano aprovado:
- schemas/contact.ts: contactCreateSchema (nome único via invariante, state
  obrigatório, phones opcionais C112); contactFieldUpdateSchema ganha variantes
  city e postalCode ('' → clear); mensagens das actions client-safe aqui.
- schemas/personGenders.ts: enum de gênero em módulo folha (ciclo de imports
  contact ↔ leadership detectado e quebrado).
- actions/contact.ts: createContact (gate staff + bypass justificado, lock
  contact-name:{normalized}) e updateContactField (escopo 'edita o que vê'
  via re-check user-threaded para advisor; reorder de telefone primário).
  Apagar reusa as actions C100 existentes.
- collections Contact e Supporter: label de gênero 'Outro' → 'Não binário'
  (sem migration; nenhum teste pina a label).
- 11 testes unit novos; lint, tsc e prettier limpos.
2026-08-13 14:12:53 -03:00

228 lines
6 KiB
TypeScript

import type { CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
canCreateSupporter,
canDeleteSupporter,
canManageCampaignStaffField,
canManageSupporter,
canReadCampaignStaffField,
canReadSupporter,
canSetAdministrativeLeadershipField,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
export const Supporter: CollectionConfig = {
slug: 'supporter',
labels: {
singular: 'Apoiador',
plural: 'Apoiadores',
},
admin: {
group: 'Campanha',
useAsTitle: 'contact',
defaultColumns: ['contact', 'municipality', 'voteIntention', 'source', 'updatedAt'],
},
access: {
create: canCreateSupporter,
read: canReadSupporter,
update: canManageSupporter,
delete: canDeleteSupporter,
},
hooks: {
beforeChange: [
async ({ data, operation, originalDoc, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
}
const contactID = relationshipId(data.contact ?? originalDoc?.contact)
const municipalityID = relationshipId(data.municipality ?? originalDoc?.municipality)
if (!contactID || !municipalityID) return data
const existingLeadership = await req.payload.find({
collection: 'leadership',
where: {
and: [{ contact: { equals: contactID } }, { municipalities: { in: [municipalityID] } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
if (existingLeadership.totalDocs > 0) {
throw new APIError(
'Este contato já é liderança neste município e não pode ser cadastrado como apoiador.',
409,
)
}
return data
},
],
afterChange: [
async ({ doc, operation, req }) => {
if (operation !== 'create') return doc
const { notifySupporterCreated } =
await import('@/utilities/notification/notificationEvents')
await notifySupporterCreated(req, doc)
return doc
},
],
},
fields: [
{
name: 'contact',
type: 'relationship',
relationTo: 'contact',
label: 'Contato',
required: true,
index: true,
access: {
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
index: true,
access: {
update: canManageCampaignStaffField,
},
},
{
name: 'voteIntention',
type: 'select',
label: 'Intenção de voto',
index: true,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
options: [
{ label: 'Certo', value: 'certo' },
{ label: 'Tende a certo', value: 'tende_a_certo' },
{ label: 'Indeciso', value: 'indeciso' },
{ label: 'Não binário', value: 'outro' },
],
},
{
name: 'consent',
type: 'relationship',
relationTo: 'consent',
label: 'Consentimento de cadastro',
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'consentContentHash',
type: 'text',
label: 'Hash do consentimento de cadastro',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'consentedAt',
type: 'date',
label: 'Consentimento de cadastro em',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'voteIntentionConsent',
type: 'relationship',
relationTo: 'consent',
label: 'Consentimento de intenção de voto',
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'voteIntentionConsentContentHash',
type: 'text',
label: 'Hash do consentimento de intenção',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'voteIntentionConsentedAt',
type: 'date',
label: 'Consentimento de intenção em',
admin: {
readOnly: true,
},
access: {
create: canSetAdministrativeLeadershipField,
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'source',
type: 'select',
label: 'Origem',
required: true,
defaultValue: 'manual',
index: true,
options: [
{ label: 'Importação CSV', value: 'import_csv' },
{ label: 'Cadastro manual', value: 'manual' },
{ label: 'Liderança', value: 'lideranca' },
{ label: 'Convite', value: 'convite' },
{ label: 'Evento', value: 'evento' },
],
access: {
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'consentNote',
type: 'textarea',
label: 'Registro de consentimento externo',
maxLength: 2000,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'notes',
type: 'textarea',
label: 'Observações internas',
maxLength: 3000,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
systemStampedActorField({
readAccess: canReadCampaignStaffField,
setAccess: canSetAdministrativeLeadershipField,
}),
],
}