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

192 lines
5 KiB
TypeScript

import { CitiesByState } from '@/lib/cities'
import {
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
normalizeBrazilianPhone,
} from '@/lib/phone'
import { trimmedText } from '@/lib/text'
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
type ContactPhoneValue = { value: string }
/**
* Normalizes the ficha's phone list: each entry is a valid Brazilian mobile
* (11 digits, `^[1-9]{2}9\d{8}$`), empty entries are dropped, and the same
* number cannot be stored twice in the same ficha (the person already has it;
* sharing a number BETWEEN fichas stays allowed — C111). `undefined` (admin
* untouched) passes through; `null`/`''` collapse to an empty list.
*/
const normalizeContactPhones = (data: unknown): ContactPhoneValue[] => {
const phones = Array.isArray(data) ? data : []
const seen = new Set<string>()
const normalized: ContactPhoneValue[] = []
for (const entry of phones) {
if (entry === null || entry === undefined) continue
const raw =
typeof entry === 'object' && 'value' in (entry as Record<string, unknown>)
? (entry as Record<string, unknown>).value
: entry
if (raw === null || raw === undefined || raw === '') continue
const input = String(raw)
let value: string | null
if (/^\d{11}$/.test(input)) {
if (/^[1-9]{2}9\d{8}$/.test(input)) {
value = input
} else {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
} else {
value = normalizeBrazilianPhone(input)
if (!value) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
}
if (seen.has(value)) {
throw new APIError(BRAZILIAN_PHONE_DUPLICATE_MESSAGE, 400)
}
seen.add(value)
normalized.push({ value })
}
return normalized
}
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (operation !== 'update' || !data || data.name === undefined || !originalDoc) return data
const currentName = trimmedText(originalDoc.name)
const nextName = trimmedText(data.name)
if (nextName === currentName) return data
const linkedDeputy = await req.payload.find({
collection: 'stateDeputy',
where: { contact: { equals: originalDoc.id } },
depth: 0,
limit: 1,
pagination: false,
// Intentional bypass: Contact access is broader than the deputy's scope; this
// lookup only determines whether the name invariant applies.
overrideAccess: true,
req,
})
const stateDeputyID = linkedDeputy.docs[0]?.id
if (stateDeputyID !== undefined) {
await assertStateDeputyNameAvailable(req.payload, req, nextName, stateDeputyID)
}
return data
}
export const Contact: CollectionConfig = {
slug: 'contact',
labels: {
singular: 'Contato',
plural: 'Contatos',
},
admin: {
group: 'Contatos',
useAsTitle: 'name',
},
access: {
create: canManageContacts,
read: canReadContacts,
update: canManageContacts,
delete: canManageContacts,
},
hooks: {
beforeValidate: [
({ data }) => {
if (!data || data.phones === undefined) return data
if (data.phones === null || data.phones === '') {
data.phones = []
return data
}
data.phones = normalizeContactPhones(data.phones)
return data
},
],
beforeChange: [enforceStateDeputyName],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
minLength: 2,
maxLength: 120,
required: true,
},
{
name: 'email',
type: 'email',
label: 'E-mail',
required: false,
},
{
name: 'phones',
type: 'array',
label: 'Telefones',
// Optional at the collection level so name-only records (e.g. leaderships imported
// from the projection sheet) can exist; UI flows still require it via zod schemas.
required: false,
fields: [
{
name: 'value',
type: 'text',
label: 'Celular',
minLength: 11,
maxLength: 11,
index: true,
},
],
},
{
name: 'gender',
type: 'select',
label: 'Gênero',
options: [
{ label: 'Feminino', value: 'feminino' },
{ label: 'Masculino', value: 'masculino' },
{ label: 'Não binário', value: 'outro' },
{ label: 'Não informado', value: 'nao_informado' },
],
},
{
name: 'state',
type: 'select',
label: 'Estado',
options: Object.keys(CitiesByState) as (keyof typeof CitiesByState)[],
required: true,
},
{
name: 'city',
type: 'text',
label: 'Cidade',
minLength: 2,
maxLength: 100,
required: false,
},
{
name: 'postalCode',
type: 'text',
label: 'CEP',
minLength: 8,
maxLength: 8,
},
],
}