mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
- 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
106 lines
3.6 KiB
TypeScript
106 lines
3.6 KiB
TypeScript
import 'server-only'
|
|
|
|
import type { Payload, PayloadRequest } from 'payload'
|
|
|
|
import type { Contact } from '@/payload-types'
|
|
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks'
|
|
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
|
|
|
type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
|
|
|
|
/**
|
|
* Find-or-create the normalized `Contact` ficha for a person, inside the
|
|
* caller's transaction. With phones, the advisory locks are acquired HERE for
|
|
* every typed number and an existing ficha that already carries the PRIMARY
|
|
* (first) phone is reused — but only when that phone is unambiguous (exactly
|
|
* one ficha): that is the phone-matching dedupe C6/C99 keeps for re-imports
|
|
* and re-registration of the SAME person. When the primary phone already
|
|
* belongs to TWO OR MORE fichas there is no person the phone can identify —
|
|
* C111 — so a fresh ficha is created (the identity of the person being
|
|
* created is the ficha just written; never a guess among the existing ones).
|
|
* The remaining typed numbers follow the ficha wherever it lands: they fill
|
|
* the fresh ficha, or are appended (only the ones it lacks, at the END — a
|
|
* re-registration never reorders the mesa's priority). Without phones, a
|
|
* fresh name-only ficha is created. Every "cria ou vincula a ficha" path
|
|
* shares one owner for the "BA default + shared-phone" policy.
|
|
*
|
|
* Callers must already be inside an active Payload transaction (the advisory
|
|
* lock requires it) and authorized to own the write (staff creation flows run
|
|
* with the operation already gated; the intentional admin bypass mirrors
|
|
* `createStateDeputyWithContact`).
|
|
*/
|
|
export const findOrCreateContactByPhone = async ({
|
|
payload,
|
|
req,
|
|
phones,
|
|
name,
|
|
email,
|
|
city,
|
|
gender,
|
|
}: {
|
|
payload: Payload
|
|
req: ContactIdentityRequest
|
|
phones: string[]
|
|
name: string
|
|
email?: string | null
|
|
city?: string | null
|
|
gender?: Contact['gender'] | null
|
|
}): Promise<{ contactID: number; reused: boolean }> => {
|
|
const primary = phones[0] ?? null
|
|
|
|
if (primary) {
|
|
await acquireContactPhoneLocks(payload, req, phones)
|
|
|
|
const contacts = await payload.find({
|
|
collection: 'contact',
|
|
// Matches ANY of the ficha's phones — the primary typed here is the
|
|
// dedupe key, but it may sit at any position of the existing ficha.
|
|
where: { 'phones.value': { equals: primary } },
|
|
depth: 0,
|
|
limit: 2,
|
|
pagination: false,
|
|
// Intentional admin bypass: the phone match must see every ficha,
|
|
// including ones outside the actor's campaign scope.
|
|
overrideAccess: true,
|
|
req,
|
|
})
|
|
|
|
if (contacts.totalDocs === 1) {
|
|
const existing = contacts.docs[0]!
|
|
const existingPhones = (existing.phones ?? [])
|
|
.map((entry) => entry.value)
|
|
.filter((value): value is string => Boolean(value))
|
|
const missing = phones.filter((phone) => !existingPhones.includes(phone))
|
|
if (missing.length > 0) {
|
|
await payload.update({
|
|
collection: 'contact',
|
|
id: existing.id,
|
|
data: {
|
|
phones: [...existingPhones, ...missing].map((value) => ({ value })),
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
req,
|
|
})
|
|
}
|
|
return { contactID: existing.id, reused: true }
|
|
}
|
|
}
|
|
|
|
const contact = await payload.create({
|
|
collection: 'contact',
|
|
data: {
|
|
name,
|
|
phones: phones.map((value) => ({ value })),
|
|
email: email ?? null,
|
|
state: 'BA' as Contact['state'],
|
|
city: city ?? null,
|
|
gender,
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
req,
|
|
})
|
|
|
|
return { contactID: contact.id, reused: false }
|
|
}
|