teqo/src/lib/phone.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

98 lines
3.8 KiB
TypeScript

const BRAZIL_COUNTRY_CODE = '55'
const BRAZILIAN_MOBILE_PHONE_LENGTH = 11
/** One refusal copy for the URL builder, the zod primitive and the Contact hook — rewording in one layer only would give the same refusal two phrasings. */
export const BRAZILIAN_PHONE_INVALID_MESSAGE = 'Celular brasileiro inválido.'
/** A ficha may not hold the same number twice (the person already has it); sharing a number BETWEEN fichas stays allowed (C111). */
export const BRAZILIAN_PHONE_DUPLICATE_MESSAGE = 'Telefone repetido na ficha.'
export const sanitizeBrazilianPhoneInput = (value: string): string => {
const digits = value.replace(/\D/g, '')
const domesticDigits =
digits.length > BRAZILIAN_MOBILE_PHONE_LENGTH && digits.startsWith(BRAZIL_COUNTRY_CODE)
? digits.slice(BRAZIL_COUNTRY_CODE.length)
: digits
return domesticDigits.slice(0, BRAZILIAN_MOBILE_PHONE_LENGTH)
}
export const formatBrazilianPhoneInput = (value: string): string => {
const digits = sanitizeBrazilianPhoneInput(value)
if (!digits) return ''
if (digits.length <= 2) return `(${digits}`
if (digits.length <= 7) return `(${digits.slice(0, 2)}) ${digits.slice(2)}`
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`
}
export const normalizeBrazilianPhone = (value: string): string | null => {
let digits = value.replace(/\D/g, '')
if (digits.length === BRAZILIAN_MOBILE_PHONE_LENGTH + BRAZIL_COUNTRY_CODE.length) {
if (!digits.startsWith(BRAZIL_COUNTRY_CODE)) return null
digits = digits.slice(BRAZIL_COUNTRY_CODE.length)
}
if (!/^[1-9]{2}9\d{8}$/.test(digits)) return null
return digits
}
/** Display formatting for a normalized 11-digit mobile: (71) 99999-9999 — the input formatter's final shape, named for display call sites. */
export const formatBrazilianPhoneDisplay = formatBrazilianPhoneInput
export const buildWhatsAppUrl = (phone: string, message?: string): string => {
const normalizedPhone = normalizeBrazilianPhone(phone)
if (!normalizedPhone) {
throw new Error(BRAZILIAN_PHONE_INVALID_MESSAGE)
}
const url = new URL(`https://wa.me/${BRAZIL_COUNTRY_CODE}${normalizedPhone}`)
if (message) {
url.searchParams.set('text', message)
}
return url.toString()
}
/** `wa.me` href for a row's phone, or `null` when it doesn't normalize — the shared guard behind every per-row WhatsApp button in the campaign lists. */
export const whatsAppHrefForPhone = (phone: string | null): string | null => {
if (!phone || !normalizeBrazilianPhone(phone)) return null
return buildWhatsAppUrl(phone)
}
/**
* The person's primary phone from the `Contact.phones` array: order is
* priority (first = primary), so the row/list/WhatsApp/convite readers all go
* through this single translation and never touch the array shape.
*/
export const primaryPhoneOf = (
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
): string | null => phones?.[0]?.value || null
/** Every number of the ficha in priority order, empties dropped. */
export const phoneValuesOf = (
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
): string[] =>
(phones ?? []).map((entry) => entry.value).filter((value): value is string => Boolean(value))
/**
* Edit the primary phone preserving the rest: the next primary goes first
* (any earlier occurrence removed — the number the person already had moves,
* never duplicates), and an empty `primary` removes the current first number
* so the rest shifts up.
*/
export const reorderWithPrimaryPhone = (
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
primary: string | null,
): string[] => {
const current = (phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value))
const rest = current.filter((phone) => phone !== primary)
return primary ? [primary, ...rest] : current.slice(1)
}