teqo/src/collections/Contact.ts
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -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: 'Outro', 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,
},
],
}