Merge pull request #665 from fsolla/C112-multiplos-telefones-por-pessoa

C112 — Múltiplos telefones por pessoa
This commit is contained in:
Francisco Solla 2026-08-11 14:27:47 +00:00 committed by GitHub
commit c83bd779d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 15116 additions and 389 deletions

View file

@ -0,0 +1,71 @@
# Impl: Múltiplos telefones por pessoa
Status: aprovado
Atualizado em: 2026-08-11
Issue: #626
Intenção: docs/plans/multiplos-telefones-por-pessoa.md
Appetite restante: ~1–2 dias eng (intenção); sem cortes de aceite
## Leitura da intenção
- **Outcome:** toda pessoa (liderança, apoiador, dobradinha, assessor) tem N telefones na ficha; listas mostram 1 (o principal); ficha mostra todos; WhatsApp/convite/import usam o principal; busca por telefone acha por qualquer número; guardrail C111 (números compartilhados entre pessoas) preservado.
- **O que NÃO negociar:** ordem = prioridade sem flag explícita; sem rótulos/tipos de telefone; import CSV com uma coluna; login da liderança continua o `username` (não muda); dedupe dentro da MESMA ficha bloqueia salvar; **UI pública intocada** — formulários de petição/WhatsApp (e o componente `PhoneInput`) não mudam; a mudança de dados entra só no mapeamento server-side das ações públicas.
- **O que reavaliar:** a hipótese da intenção listava `src/lib/phone.ts`, view models e import — ok; mas **assessor** tem camada extra (o telefone mora em `campaignUser.phone`, espelhado na ficha) e **`Contact.phone` é lido pelo site público** (petição/WhatsApp) e por export — a intenção não citava esses dois.
## Abordagem recomendada
```mermaid
flowchart LR
C[Contact] -->|array field| P[phones: {value}[]]
P -->|primary| H[primaryPhoneOf]
H --> VM[view models / listas / wa.me / convite]
W[formulários criação/edição] -->|PhonesFieldEditor| P
S[busca/import/findOrCreate] -->|where phones.value| P
```
**Opções consideradas:** A | B | C
**Recomendação:** A — array field Payload `phones` com subcampo `value` na `Contact`, removendo `phone` (fonte única). Payload onde com `'phones.value': { equals/contains }` resolve busca/dedupe com JOIN nativo, `_order` preserva ordem = prioridade, e o admin UI ganha a edição de graça.
**Rejeitadas:**
- B — `jsonb phones: string[]` (custom/JSON): perde query/index Payload; `supporterListSqlFilters` e `supporterImportBulk` teriam que viver de SQL cru e a busca Payload (supporter filters, pickers) ficaria sem where nativo. Pior contrato para o caso de uso mais comum.
- C — collection join `contactPhone`: entidade demais para um campo sem rótulos/tipos; clareza zero de "principal" (teria `_order` escondido do mesmo jeito); admin group extra.
- D — manter `phone` + `phones` desnormalizados: duas fontes de verdade sincronizando em N write paths; rejeitado por invariante (uma fonte).
### Componentes / mudanças
- **`phones`** (`src/collections/Contact.ts`): array field `phones` (label "Telefones", subcampo `value` text 11 dígitos, `required: false`); remove o campo `phone`. `beforeValidate` da collection normaliza cada item (`normalizeBrazilianPhone` / regex 11 dígitos), filtra vazios, rejeita inválido com `BRAZILIAN_PHONE_INVALID_MESSAGE` e rejeita **duplicado dentro da ficha** (nova copy: `BRAZILIAN_PHONE_DUPLICATE_MESSAGE` = "Telefone repetido na ficha.").
- **Migration** `add_contact_phones_array`: tabela gerada `contact_phones` (\_parent_id, \_order, value) + backfill manual `INSERT ... SELECT id, phone, 0 FROM contact WHERE phone IS NOT NULL` + `DROP COLUMN contact.phone` (derruba `contact_phone_idx` junto) + índice manual `contact_phones (value)` (Payload não indexa subcampo de array; o equals/in da busca precisa).
- **`primaryPhoneOf`** (`src/lib/phone.ts`): `phones[0]?.value ?? null` — o único tradutor array→principal. Todos os readers de lista/ficha passam por ele (view models `leadershipData`, `stateDeputyData`, `peopleData`, `supporterViewModels`, `leaderContactsPageData`, `campaignInvitePageData`, `activityViewModels`, home search, `MunicipalityDossier`, `ContactCombobox`, `wizardLeadershipContract`).
- **`setContactPhones`** (nova utility `src/utilities/contactPhones.ts`): read-modify-write transacional do array (usada pelas actions de edição inline "seta principal preservando o resto" e pelos editors de ficha); `normalizeContactPhones` (pure, compartilhada com o hook e com o zod).
- **`PhonesFieldEditor`** (novo componente `src/components/campaign/shared/PhonesFieldEditor.tsx`): inputs mascarados repetíveis (reusa `FormattedInput` + `formatBrazilianPhoneInput`), remover por linha, "+ Adicionar telefone", setas ↑/↓ de reordenação (ordem = prioridade; sem arrasto — anti-goal). Serializa `phones` repetido no FormData (padrão `repeatedRelationshipFormValues`; novo `repeatedPhoneFormValues` em `src/lib/formData.ts`).
- **Schemas** (`src/lib/schemas/`): **`contactSchema` permanece com `phone`** (só o público usa — petição/WhatsApp); as ações públicas mapeiam `phone → phones: [{ value }]` ao gravar a `Contact` (UI e zod públicos intocados). Nos schemas de campanha: `leadershipCreateSchema`/`wizard`/`supporter`/`leaderSupporter`/`invite` trocam `phone` por `phones` (array, min 1 nos fluxos que exigiam); `contactFieldUpdateSchema`: branch `field: 'phone'` passa a significar "escrever principal preservando o resto" e novo branch `field: 'phones'` (array completo) para os editors de ficha.
- **Access / Consent:** nenhum (campo interno de `Contact`; sem Consent novo).
- **UI:** Impeccable B — formulários de criação/edição + ficha + listas existentes; shape→craft→critique→polish no editor compartilhado; shells e células inline existentes (`CampaignInlineEditableCell`) permanecem para as listas (mostram o principal, editam o principal).
## Fases verificáveis
1. **Tracer schema+server** — campo array + hook + migration (backfill+drop+índice) + `primaryPhoneOf` + `findOrCreateContactByPhone` via `phones.value` + view models trocando para o principal + ações públicas mapeando `phone → phones[0]` (sem tocar UI/zod públicos) + `signatureExport` + seed/fixtures. **Spike aqui:** confirmar semântica do `where: { 'phones.value': { equals/contains } }` em Payload 3.82 (int spec) — se falhar, fallback é JOIN cru nos poucos sites Payload-where.
2. **Write paths** — zod/schemas das 5 superfícies + actions (leadership create/wizard/inline, supporter create/tombstone LGPD, stateDeputy inline, leaderSupporter, convite redemption, sync `CampaignUser` (append se faltar), import CSV preview+bulk) + where de busca (`supporterListFilters`, `supporterListSqlFilters`, pickers de atividade, combobox).
3. **UI** — `PhonesFieldEditor` + forms de criação (LeadershipForm, SupporterForm, LeaderContactForm, WizardLeadershipForm) + fichas (LeadershipContactSection, StateDeputyContactSection, "Dados de contato" do apoiador com editor novo, assessor ganha seção "Telefones" na ficha) + células inline inalteradas na lista.
4. **Gates** — unit (primaryPhoneOf, normalize/dedupe, schemas, formData) + int (multi-telefone nas 5 superfícies, dedupe bloqueia, principal preservado no inline, import, convite, busca por qualquer número, tombstone substitui TODOS os telefones, sync append) + `pnpm gate:fast` + `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não adicionar busca por telefone nas listas que hoje só buscam por nome (lideranças, dobradinhas, pessoas, assessores) — o aceite é "a busca por telefone encontra por qualquer número" onde busca por telefone existe (apoiadores, pickers); adicionar nas outras é item futuro se a mesa pedir. **Gatilho de revisitação:** mesa perguntar "por que não acho a liderança pelo número na lista de lideranças".
- Sem cap de quantidade de telefones; sem flag "principal"; sem arrasto (setas são suficientes para trocar prioridade).
- `campaignUser.phone` permanece campo único da conta (canal de contato/login); não vira array.
- Sem espelho ficha→conta (conta continua espelhando UNICAMENTE via sync existente conta→ficha, agora append).
## Riscos e mitigação
- **Payload onde em subcampo de array** (`'phones.value'`): spike na Fase 1 com int spec; fallback JOIN cru documentado nos poucos sites.
- **Migration destrutiva em prod (PII real):** backfill roda ANTES do drop no mesmo migration; `migrate:status` local + revisão do SQL no checklist de deploy.
- **Churn de testes** (muitos specs leem `contact.phone`): helper de fixture `createContact` continua aceitando `phone` e mapeia internamente para `phones` — maioria dos specs int fica intocada; asserts com `where: { phone }` viram `where: { 'phones.value': … }`.
- **Race do sync conta→ficha (append)**: sem transação garantida no hook; se o append colidir com edição concorrente, o dedupe fail-closed rejeita o save da conta (raro, mensagem clara).
- **Inline edit de lista** apagar principal: "limpar o campo" remove o index 0 (o resto sobe); nunca zera os demais.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (N telefones, lista=principal, ficha=todos, wa.me/convite/import=principal, busca por qualquer, C111 preservado, dedupe interno)
- [ ] Invariantes AGENTS/engineering-standards (fonte única em `Contact`; transação+req nas escritas multi-collection; copy pt-BR; identificadores inglês)
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — acima

View file

@ -199,8 +199,12 @@ try {
for (const entry of MINIMAL_LEADERSHIPS) {
const contact = await upsert(
'contact',
{ phone: { equals: entry.contactPhone } },
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
{ 'phones.value': { equals: entry.contactPhone } },
{
name: entry.contactName,
phones: [{ value: entry.contactPhone }],
state: 'BA',
},
`contact ${entry.contactPhone}`,
)
const consent = consentIdByKey.get(entry.consentKey)
@ -226,8 +230,12 @@ try {
for (const entry of MINIMAL_SUPPORTERS) {
const contact = await upsert(
'contact',
{ phone: { equals: entry.contactPhone } },
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
{ 'phones.value': { equals: entry.contactPhone } },
{
name: entry.contactName,
phones: [{ value: entry.contactPhone }],
state: 'BA',
},
`contact ${entry.contactPhone}`,
)
const municipalityID = await municipalityIdBySlug(entry.municipalitySlug)

View file

@ -5,7 +5,12 @@ import {
createLeadershipWizard,
updateLeadershipWizard,
} from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
optionalFormText,
repeatedPhoneFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_INVALID_CONTACT_MESSAGE,
@ -37,7 +42,7 @@ const parseSupportStatus = (formData: FormData) => {
const wizardFieldsFromForm = (formData: FormData) => ({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
email: optionalFormText(formData, 'email'),
exclusive: parseExclusive(formData),
supportStatus: parseSupportStatus(formData),

View file

@ -10,9 +10,15 @@ import { redirect } from 'next/navigation'
import {
removeSupporterData,
setSupporterVoteIntention,
updateSupporterContact,
} from '@/app/(campaign)/campanha/actions/supporter'
import { SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
import { checkboxFormValue, requiredRelationshipFormValue } from '@/lib/formData'
import {
checkboxFormValue,
repeatedPhoneFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE, BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import {
SUPPORTER_STAFF_MESSAGE,
@ -21,6 +27,8 @@ import {
} from '@/lib/schemas/supporter'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
type CampaignFormErrorState,
} from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
@ -41,6 +49,12 @@ const safeVoteIntentionMessages = [
SUPPORTER_STAFF_MESSAGE,
] as const
const safeContactMessages = [
SUPPORTER_STAFF_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
] as const
const toMessageOnlyState = (mapped: CampaignFormErrorState<unknown>): { message?: string } => {
const message = firstFormActionMessage(mapped)
return message ? { message } : {}
@ -98,3 +112,20 @@ export const removeSupporterDataFormAction = async (
)
}
}
export const updateSupporterContactFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateSupporterContact({
id: requiredRelationshipFormValue(formData, 'supporterId'),
field: 'phones',
phones: repeatedPhoneFormValues(formData, 'phones'),
})
return { message: 'Telefones atualizados.' }
},
safeMessages: safeContactMessages,
genericMessage: 'Não foi possível salvar os telefones.',
})

View file

@ -4,8 +4,12 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import {
setSupporterVoteIntentionFormAction,
updateSupporterContactFormAction,
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
@ -112,7 +116,15 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
<CardTitle className="text-base">Dados de contato</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p className="tabular-nums">{supporter.phoneDisplay || '—'}</p>
<PhonesFieldEditor
defaultValues={supporter.phones}
label="Celulares"
saveAction={updateSupporterContactFormAction}
recordId={supporter.id}
recordIdField="supporterId"
saveLabel="Salvar"
cancelLabel="Cancelar"
/>
{supporter.email ? <p>{supporter.email}</p> : null}
<p>
{supporter.municipalityName ? (

View file

@ -7,8 +7,12 @@ import {
SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE,
SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE,
} from '@/lib/campaignConsentKeys'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
import {
checkboxFormValue,
nullableRelationshipFormValue,
optionalFormText,
repeatedPhoneFormValues,
} from '@/lib/formData'
import {
SUPPORTER_DUPLICATE_MESSAGE,
SUPPORTER_STAFF_MESSAGE,
@ -28,7 +32,7 @@ export type SupporterFormState = {
type SupporterFormValues = {
name?: string
phone?: string
phones?: string[]
email?: string
city?: string
municipality?: string
@ -50,7 +54,7 @@ export const createSupporterFormAction = async (
// Parsed before the ladder (non-throwing readers) so failures echo them back.
const values: SupporterFormValues = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
municipality: optionalFormText(formData, 'municipality'),
@ -59,13 +63,12 @@ export const createSupporterFormAction = async (
return runCampaignFormAction({
execute: async () => {
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
const municipality = nullableRelationshipFormValue(formData, 'municipality')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
phones: values.phones ?? [],
email: values.email,
city: values.city,
...(municipality ? { municipality } : {}),

View file

@ -6,6 +6,7 @@ import { getPayload } from 'payload'
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
@ -14,7 +15,11 @@ import { formatBrazilianPhoneInput } from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { loadAdvisorDetail } from '@/utilities/advisorData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
import {
sendAdvisorPasswordResetFormAction,
updateAdvisorContactFormAction,
updateAdvisorProfileFormAction,
} from '../formActions'
export async function generateMetadata({ params }: AdvisorDetailPageProps) {
const { id: rawId } = await params
@ -115,6 +120,29 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
)}
</section>
<section
aria-labelledby="advisor-contact-title"
className="flex flex-col gap-2 rounded-xl border p-4"
>
<h2 id="advisor-contact-title" className="text-base font-medium">
Telefones da pessoa
</h2>
<p className="text-sm text-muted-foreground">
Todos os números da ficha — o primeiro é o principal (listas, WhatsApp e convites).
</p>
{advisor.contactID !== null ? (
<PhonesFieldEditor
defaultValues={advisor.fichaPhones}
label="Telefones"
saveAction={updateAdvisorContactFormAction}
recordId={advisor.contactID}
recordIdField="contactId"
/>
) : (
<p className="text-sm text-muted-foreground">Ficha de contato ainda não vinculada.</p>
)}
</section>
<section aria-labelledby="advisor-password-title" className="flex flex-col gap-3">
<h2 id="advisor-password-title" className="text-base font-medium">
Acesso

View file

@ -4,10 +4,12 @@ import {
createAdvisor,
sendAdvisorPasswordReset,
setAdvisorMunicipalitiesBatch,
updateAdvisorContactFicha,
updateAdvisorProfile,
} from '@/app/(campaign)/campanha/actions/advisor'
import {
nullableFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
@ -88,6 +90,26 @@ export const sendAdvisorPasswordResetFormAction = async (
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
})
/**
* C112 — the advisor's ficha (Contact) phone list. The account keeps its own
* single channel phone ("Conta"); this editor targets the person's ficha.
*/
export const updateAdvisorContactFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateAdvisorContactFicha({
contactId: requiredRelationshipFormValue(formData, 'contactId'),
phones: repeatedPhoneFormValues(formData, 'phones'),
})
return { message: 'Telefones atualizados.' }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível salvar os telefones.',
})
export const createAdvisorFormAction = async (
_state: CampaignFormActionState,
formData: FormData,

View file

@ -5,6 +5,7 @@ import { getPayload } from 'payload'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { primaryPhoneOf } from '@/lib/phone'
import {
searchActivityResponsibleOptions,
type ActivityResponsibleSearchOption,
@ -28,18 +29,22 @@ export const searchActivityContactOptions = async (
const result = await payload.find({
collection: 'contact',
where: {
or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
or: [{ name: { contains: trimmed } }, { 'phones.value': { contains: digits || trimmed } }],
},
depth: 0,
limit: CONTACT_OPTION_LIMIT,
page: 1,
sort: 'name',
select: { name: true, phone: true },
select: { name: true, phones: { value: true } },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
return result.docs.map(({ id, name, phones }) => ({
id,
name,
phone: primaryPhoneOf(phones),
}))
}
export const searchActivityResponsibleOptionsAction = async (

View file

@ -69,7 +69,7 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
stateDeputyId={stateDeputy.id}
name={stateDeputy.name}
email={stateDeputy.email}
phone={stateDeputy.phone}
phones={stateDeputy.phones}
/>
{stateDeputy.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>

View file

@ -9,6 +9,7 @@ import {
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
@ -69,6 +70,12 @@ export const updateStateDeputyContactFormAction = async (
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else if (field === 'phones') {
await updateStateDeputyContact({
id,
field: 'phones',
phones: repeatedPhoneFormValues(formData, 'phones'),
})
} else {
throw new Error(STATE_DEPUTY_INVALID_FIELD_MESSAGE)
}

View file

@ -97,7 +97,7 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
leadershipId={leadership.id}
name={leadership.name}
email={leadership.email}
phone={leadership.phone}
phones={leadership.phones}
/>
{leadership.stateDeputies.length > 0 ? (
<StateDeputyChips deputies={leadership.stateDeputies} />

View file

@ -8,6 +8,7 @@ import {
} from '@/app/(campaign)/campanha/actions/leadership'
import {
nullableFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
@ -65,6 +66,12 @@ export const updateLeadershipContactFormAction = async (
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else if (field === 'phones') {
await updateLeadershipContact({
id,
field: 'phones',
phones: repeatedPhoneFormValues(formData, 'phones'),
})
} else {
throw new Error(INVALID_FIELD_MESSAGE)
}

View file

@ -2,7 +2,12 @@
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import {
optionalFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormText,
} from '@/lib/formData'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
@ -33,7 +38,7 @@ export const createLeadershipFormAction = async (
return createLeadership({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
email: optionalFormText(formData, 'email'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),

View file

@ -23,6 +23,7 @@ import {
type AdvisorPasswordResetInput,
type AdvisorProfileUpdateInput,
} from '@/lib/schemas/advisor'
import { contactPhonesSchema } from '@/lib/schemas/contact'
import type { CampaignUser } from '@/payload-types'
import {
getCampaignActionContext,
@ -163,6 +164,29 @@ export const updateAdvisorProfile = async (input: AdvisorProfileUpdateInput) =>
return updated
}
/**
* C112 — write the advisor's ficha (Contact) phone list. The advisor page is
* unrestricted-staff gated; the ficha is the person's record, distinct from
* the account's single channel phone.
*/
export const updateAdvisorContactFicha = async (input: { contactId: number; phones: string[] }) => {
const { payload, actor } = await getCampaignActionContext()
await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
// Same zod gate as the other ficha editors: valid mobiles, no duplicates.
const parsed = contactPhonesSchema.parse(input.phones)
await payload.update({
collection: 'contact',
id: input.contactId,
data: { phones: parsed.map((value) => ({ value })) },
depth: 0,
// Intentional admin bypass: the unrestricted route gate above established
// staff scope; the ficha is the advisor's own normalized Contact.
overrideAccess: true,
})
revalidatePath('/campanha/assessores', 'page')
}
export const setAdvisorMunicipalitiesBatchRecord = async (
payload: Payload,
actor: CampaignUser,

View file

@ -4,8 +4,12 @@ import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
import {
checkboxFormValue,
nullableRelationshipFormValue,
optionalFormText,
repeatedPhoneFormValues,
} from '@/lib/formData'
import {
LEADER_SUPPORTER_ONLY_MESSAGE,
leaderSupporterCreateSchema,
@ -32,7 +36,7 @@ export type LeaderSupporterFormState = {
type LeaderSupporterFormValues = {
name?: string
phone?: string
phones?: string[]
city?: string
municipality?: string
}
@ -89,7 +93,7 @@ export const createLeaderSupporterRecord = async (
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone,
phones: data.phones,
name: data.name,
city: data.city,
})
@ -133,18 +137,17 @@ export const createLeaderSupporterFormAction = async (
): Promise<LeaderSupporterFormState> => {
const values: LeaderSupporterFormValues = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
city: optionalFormText(formData, 'city'),
municipality: optionalFormText(formData, 'municipality'),
}
return runCampaignFormAction({
execute: async () => {
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const municipality = nullableRelationshipFormValue(formData, 'municipality')
const input = leaderSupporterCreateSchema.parse({
name: values.name ?? '',
phone,
phones: values.phones ?? [],
city: values.city,
municipality,
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,

View file

@ -6,6 +6,7 @@ import type { Payload } from 'payload'
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership'
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import {
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
@ -81,8 +82,8 @@ const isUniqueLeadershipConflict = (error: unknown): boolean => {
}
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phone'> & {
phone?: string | null
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phones'> & {
phones?: string[]
}
const createValidatedLeadershipRecord = async (
@ -118,7 +119,7 @@ const createValidatedLeadershipRecord = async (
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone ?? null,
phones: data.phones ?? [],
name: data.name,
email: data.email,
city: city ?? undefined,
@ -254,18 +255,25 @@ export const updateLeadershipContactRecord = async (
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
}
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
phones?: { value: string }[]
} = {}
if (data.field === 'name') {
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
contactData.phone = data.phone
} else {
contactData.phone = null
}
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
// list untouched: the new number goes first (an earlier occurrence
// moves, never duplicates) and clearing removes the primary.
const contact = current.contact
contactData.phones = reorderWithPrimaryPhone(
typeof contact === 'object' && contact !== null ? contact.phones : null,
data.phone,
).map((value) => ({ value }))
} else if (data.field === 'phones') {
contactData.phones = data.phones.map((value) => ({ value }))
}
// bypass: contact write is staff-scoped via leadership access check above.
@ -336,7 +344,7 @@ const updateLeadershipWizardRecord = async (
id: contactID,
data: {
name: data.name,
phone: data.phone,
phones: data.phones.map((value) => ({ value })),
email: data.email,
},
depth: 0,
@ -370,7 +378,7 @@ const createLeadershipWizardRecord = async (
const data = leadershipWizardCreateSchema.parse(input)
return createValidatedLeadershipRecord(payload, actor, {
name: data.name,
phone: data.phone,
phones: data.phones,
email: data.email,
municipalities: [data.municipalityId],
exclusive: data.exclusive,
@ -415,7 +423,7 @@ export const createMunicipalityLeadershipRecord = async (
const data = municipalityLeadershipCreateSchema.parse(input)
const leadership = await createValidatedLeadershipRecord(payload, actor, {
name: data.name,
phone: null,
phones: [],
municipalities: [data.municipalityId],
email: undefined,
exclusive: true,

View file

@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
@ -69,7 +70,7 @@ const createStateDeputyWithContact = async (
data: {
name: data.name,
email: null,
phone: null,
phones: [],
state: 'BA' as Contact['state'],
city: null,
},
@ -165,7 +166,7 @@ export const updateStateDeputyContactRecord = async (
const current = await payload.findByID({
collection: 'stateDeputy',
id: data.id,
depth: 0,
depth: 1,
select: { contact: true },
user: currentActor,
overrideAccess: false,
@ -175,18 +176,24 @@ export const updateStateDeputyContactRecord = async (
const contactID = relationshipId(current.contact)
if (contactID === null) throw new Error(STATE_DEPUTY_INVALID_CONTACT_MESSAGE)
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
phones?: { value: string }[]
} = {}
if (data.field === 'name') {
await assertStateDeputyNameAvailable(payload, req, data.name, data.id)
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
contactData.phone = data.phone
} else {
contactData.phone = null
}
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
// list untouched (see the leadership sibling).
const contact = current.contact
contactData.phones = reorderWithPrimaryPhone(
typeof contact === 'object' && contact !== null ? contact.phones : null,
data.phone,
).map((value) => ({ value }))
} else if (data.field === 'phones') {
contactData.phones = data.phones.map((value) => ({ value }))
}
// Bypass: the StateDeputy row access above established staff scope.

View file

@ -1,8 +1,10 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { requireRelationshipId } from '@/lib/relationship'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
SUPPORTER_DUPLICATE_MESSAGE,
SUPPORTER_STAFF_MESSAGE,
@ -37,6 +39,8 @@ const getFreshStaffActor = (
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => reloadStaffActor(payload, actor, SUPPORTER_STAFF_MESSAGE, req)
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
const assertMunicipalityManagement = async (
payload: Payload,
actor: CampaignUser,
@ -92,7 +96,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone,
phones: data.phones,
name: data.name,
email: data.email,
city: data.city,
@ -183,6 +187,54 @@ export const setSupporterVoteIntention = async (input: unknown) => {
return setSupporterVoteIntentionRecord(payload, actor, input)
}
/**
* C112 — the supporter ficha's phone list editor. Staff only (same supporter
* access gate as every supporter write); only the `phones` branch of the
* shared per-field schema is honored.
*/
export const updateSupporterContactRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFieldUpdateInput,
) => {
const data = contactFieldUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
const supporter = await assertCanManageSupporter(payload, currentActor, data.id, req)
const contactID = relationshipId(supporter.contact)
if (contactID === null) throw new Error(SUPPORTER_STAFF_MESSAGE)
if (data.field !== 'phones') {
throw new Error(INVALID_FIELD_MESSAGE)
}
// bypass: the supporter row access above established staff scope.
await payload.update({
collection: 'contact',
id: contactID,
data: { phones: data.phones.map((value) => ({ value })) },
depth: 0,
overrideAccess: true,
req,
})
return supporter
},
{ beginFailureMessage: 'Não foi possível atualizar o contato do apoiador.' },
)
}
export const updateSupporterContact = async (input: ContactFieldUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
const supporter = await updateSupporterContactRecord(payload, actor, input)
revalidatePath(`/campanha/apoiadores/${supporter.id}`, 'page')
return supporter
}
// ---------------------------------------------------------------------------
// CSV import pipeline — implementation lives in ./supporterImport. A top-level
// `'use server'` module cannot re-export values (only async functions defined
@ -275,7 +327,9 @@ const anonymizeContact = async (
data: {
name: 'Titular removido',
email: null,
phone: tombstonePhone,
// The tombstone REPLACES every number — the remaining phones are the
// person's PII and must not survive the anonymization (C112).
phones: [{ value: tombstonePhone }],
gender: null,
city: null,
postalCode: null,

View file

@ -224,28 +224,35 @@ export const previewSupporterImportText = async (
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
where: { 'phones.value': { in: candidatePhones } },
depth: 0,
// No LIMIT: a phone can now legitimately match many fichas (C111), so a
// finite limit would silently miss a shared phone past the cutoff and
// the preview would promise rows the confirm then aborts.
limit: 0,
pagination: false,
select: { phone: true },
select: { phones: { value: true } },
overrideAccess: true,
})
// C111 — the phone is not unique: a CSV phone resolving to two or more
// fichas cannot be matched to a person, so every row with that phone is
// flagged for manual resolution (never a silent "last one wins").
// flagged for manual resolution (never a silent "last one wins"). Keyed by
// the CSV phone ITSELF, not the ficha's primary: the JOIN matched any
// position, so a number carried as secondary by two fichas must be shared
// here — exactly what the confirm's fail-closed check will abort on.
const candidatePhoneSet = new Set(candidatePhones)
const contactByPhone = new Map<string, number>()
const sharedPhones = new Set<string>()
for (const doc of contacts.docs) {
if (!doc.phone) continue
if (contactByPhone.has(doc.phone)) {
sharedPhones.add(doc.phone)
continue
for (const entry of doc.phones ?? []) {
const phone = entry.value
if (!phone || !candidatePhoneSet.has(phone)) continue
if (contactByPhone.has(phone)) {
sharedPhones.add(phone)
continue
}
contactByPhone.set(phone, doc.id)
}
contactByPhone.set(doc.phone, doc.id)
}
const contactIDs = [...contactByPhone.values()]

View file

@ -37,9 +37,15 @@ export const submitPetitionSignature = async ({
}
try {
const { phone, ...contactFields } = data
const contact = await payload.create({
collection: 'contact',
data,
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req: { transactionID },
})

View file

@ -28,9 +28,15 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
}
try {
const { phone, ...contactFields } = contactInput
const contact = await payload.create({
collection: 'contact',
data: contactInput,
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req: { transactionID },
})

View file

@ -146,7 +146,7 @@ const resolveContactForAccount = async ({
const { contactID } = await findOrCreateContactByPhone({
payload,
req,
phone: typeof phone === 'string' && phone.length > 0 ? phone : null,
phones: typeof phone === 'string' && phone.length > 0 ? [phone] : [],
name,
email: email && !isPlanilhaPlaceholderEmail(email) ? email : null,
})
@ -205,7 +205,11 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
const linkedContactID = relationshipId(originalDoc?.contact)
if (linkedContactID !== null) {
const contactData: { name?: string; email?: string | null; phone?: string } = {}
const contactData: {
name?: string
email?: string | null
phones?: { value: string }[]
} = {}
if (data.name !== undefined && data.name !== originalDoc?.name) {
contactData.name = data.name
@ -216,14 +220,32 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
}
}
// Clearing the account's phone never clears the ficha's: the ficha's
// phone is the person's dedupe key across every join (supporter,
// phones are the person's dedupe keys across every join (supporter,
// leadership, dobradinha), and the account edit is not a ficha edit.
// A changed account phone is APPENDED when the ficha lacks it (never
// reordering the mesa's priority, never removing the previous number);
// creation still seeds it as the primary via find-or-create.
if (
typeof data.phone === 'string' &&
data.phone.length > 0 &&
data.phone !== originalDoc?.phone
) {
contactData.phone = data.phone
const contact = await req.payload.findByID({
collection: 'contact',
id: linkedContactID,
depth: 0,
select: { phones: { value: true } },
// Intentional admin bypass: same account-scoped policy as the ficha
// write below — the read only derives the append target.
overrideAccess: true,
req,
})
const existingPhones = (contact.phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value))
if (!existingPhones.includes(data.phone)) {
contactData.phones = [...existingPhones, data.phone].map((value) => ({ value }))
}
}
if (Object.keys(contactData).length === 0) return data

View file

@ -1,11 +1,63 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone'
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,
@ -57,27 +109,14 @@ export const Contact: CollectionConfig = {
hooks: {
beforeValidate: [
({ data }) => {
if (!data || data.phone === undefined) return data
if (!data || data.phones === undefined) return data
if (data.phone === null || data.phone === '') {
data.phone = null
if (data.phones === null || data.phones === '') {
data.phones = []
return data
}
const input = String(data.phone)
if (/^\d{11}$/.test(input)) {
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
return data
}
const phone = normalizeBrazilianPhone(input)
if (!phone) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
data.phone = phone
data.phones = normalizeContactPhones(data.phones)
return data
},
],
@ -99,15 +138,22 @@ export const Contact: CollectionConfig = {
required: false,
},
{
name: 'phone',
type: 'text',
label: 'Celular',
minLength: 11,
maxLength: 11,
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,
index: true,
fields: [
{
name: 'value',
type: 'text',
label: 'Celular',
minLength: 11,
maxLength: 11,
index: true,
},
],
},
{
name: 'gender',

View file

@ -8,10 +8,10 @@ import {
type LeaderSupporterFormState,
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
@ -26,7 +26,6 @@ import {
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import { municipalityComboboxOptions } from '@/utilities/territory/territoryComboboxOptions'
@ -55,7 +54,6 @@ export const LeaderContactForm = ({
useCampaignFormSuccessToast(state)
const phoneField = errorProps(state.fieldErrors, 'phone')
const nameField = errorProps(state.fieldErrors, 'name')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
@ -88,33 +86,19 @@ export const LeaderContactForm = ({
) : null}
<FieldGroup>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
<FormattedInput
id="leader-contact-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
autoFocus
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal) *"
defaultValues={values?.phones ?? []}
error={state.fieldErrors?.phones}
/>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
<Input
id="leader-contact-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
@ -148,7 +132,6 @@ export const LeaderContactForm = ({
<NativeSelect
id="leader-contact-municipality"
name="municipality"
required
defaultValue={values?.municipality ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
@ -193,7 +176,6 @@ export const LeaderContactForm = ({
id="leader-contact-consent"
name="consentAccepted"
value="true"
required
aria-invalid={Boolean(consentError)}
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
/>

View file

@ -2,19 +2,21 @@
import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
type LeadershipContactSectionProps = {
leadershipId: number
name: string
email: string | null
phone: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
}
export const LeadershipContactSection = ({
leadershipId,
name,
email,
phone,
phones,
}: LeadershipContactSectionProps) => (
<section aria-labelledby="leadership-contact-title" className="flex flex-col gap-3">
<h2 id="leadership-contact-title" className="text-base font-medium">
@ -50,16 +52,16 @@ export const LeadershipContactSection = ({
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Celular</dt>
<dt className="text-sm text-muted-foreground">
Celulares <span className="font-normal">(o primeiro é o principal)</span>
</dt>
<dd>
<CampaignInlineEditableCell
<PhonesFieldEditor
defaultValues={phones}
label="Celulares"
saveAction={updateLeadershipContactFormAction}
recordId={leadershipId}
recordIdField="leadershipId"
field="phone"
value={phone}
label="Celular"
formAction={updateLeadershipContactFormAction}
readBehavior="copy"
/>
</dd>
</div>

View file

@ -3,6 +3,7 @@
import { useActionState } from 'react'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import {
RelationMultiSelect,
type RelationOption,
@ -46,7 +47,6 @@ export const LeadershipForm = ({
<Input
id="leadership-name"
name="name"
required
minLength={2}
maxLength={120}
className="min-h-11"
@ -56,20 +56,12 @@ export const LeadershipForm = ({
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="leadership-phone">Celular (com DDD)</FieldLabel>
<Input
id="leadership-phone"
name="phone"
type="tel"
required
autoComplete="tel-national"
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'phone') ? (
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal)"
error={fieldError(state.fieldErrors, 'phones')}
/>
<Field>
<FieldLabel htmlFor="leadership-email">E-mail (opcional)</FieldLabel>
<Input id="leadership-email" name="email" type="email" className="min-h-11" />

View file

@ -7,6 +7,7 @@ import {
updateLeadershipWizardFormAction,
} from '@/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
@ -66,7 +67,6 @@ export const WizardLeadershipForm = ({
ref={nameRef}
id="wizard-leadership-name"
name="name"
required
minLength={2}
maxLength={120}
defaultValue={leadership?.name ?? ''}
@ -78,21 +78,13 @@ export const WizardLeadershipForm = ({
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="wizard-leadership-phone">Celular</FieldLabel>
<Input
id="wizard-leadership-phone"
name="phone"
type="tel"
required
autoComplete="tel-national"
defaultValue={leadership?.phone ?? ''}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'phone') ? (
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal)"
defaultValues={leadership?.phones ?? []}
error={fieldError(state.fieldErrors, 'phones')}
/>
<Field>
<FieldLabel htmlFor="wizard-leadership-email">E-mail</FieldLabel>
<Input

View file

@ -0,0 +1,285 @@
'use client'
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FormattedInput } from '@/components/FormattedInput'
import { Button } from '@/components/ui/button'
import { FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
type PhoneRow = { key: string; value: string }
let rowKey = 0
const nextRowKey = (): string => `phone-${++rowKey}`
const EMPTY_ROW_KEY = 'phone-empty'
const displayValue = (value: string): string => formatBrazilianPhoneInput(value)
/**
* The repeatable phone list editor (C112): one masked input per number, order
* = priority (first = primary), with remove and ↑/↓ reorder — no labels, no
* drag (product cut). In a create/edit form it renders plain inputs named
* `phones` for the form action to collect; with `saveAction` it becomes the
* ficha's inline editor (Salvar/Cancelar + refresh on success).
*/
export const PhonesFieldEditor = ({
name = 'phones',
defaultValues = [],
minRows = 0,
label = 'Celulares',
error,
saveAction,
recordId,
recordIdField,
saveLabel = 'Salvar',
cancelLabel = 'Cancelar',
className,
}: {
name?: string
defaultValues?: string[]
/** Rows below this count cannot be removed (create flows that require a phone). */
minRows?: number
label?: string
error?: string | string[] | undefined
/** Inline mode: submits the full list through the per-surface contact update action. */
saveAction?: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
recordId?: number
recordIdField?: string
saveLabel?: string
cancelLabel?: string
className?: string
}) => {
const router = useRouter()
const [rows, setRows] = useState<PhoneRow[]>(() => {
const initial = defaultValues.map((value) => ({ key: nextRowKey(), value }))
while (initial.length < minRows) initial.push({ key: nextRowKey(), value: '' })
return initial
})
const [isPending, setIsPending] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(!saveAction)
const rowsRef = useRef(rows)
rowsRef.current = rows
const appliedDefaultsRef = useRef<string[]>(defaultValues)
useEffect(() => {
if (!saveAction) return
// Apply the server values only when they actually changed (a post-save
// refresh or an unrelated refresh of the page must not drop what the
// user is typing).
if (defaultValues.join('|') === appliedDefaultsRef.current.join('|')) return
appliedDefaultsRef.current = defaultValues
setRows((current) => {
const existing = new Map(current.map((row) => [row.value, row]))
const next = defaultValues.map((value) => existing.get(value) ?? { key: nextRowKey(), value })
return next.length > 0 || current.length === 0 ? next : current
})
}, [defaultValues, saveAction])
const updateRow = (key: string, value: string): void => {
setRows((current) => current.map((row) => (row.key === key ? { ...row, value } : row)))
setSaveError(null)
}
const addRow = (): void => {
setRows((current) => [...current, { key: nextRowKey(), value: '' }])
setSaveError(null)
}
const removeRow = (key: string): void => {
setRows((current) =>
current.length > minRows ? current.filter((row) => row.key !== key) : current,
)
setSaveError(null)
}
const moveRow = (index: number, direction: -1 | 1): void => {
setRows((current) => {
const target = index + direction
if (target < 0 || target >= current.length) return current
const next = [...current]
;[next[index], next[target]] = [next[target]!, next[index]!]
return next
})
setSaveError(null)
}
const errorId = 'phones-field-error'
const hasError = saveError !== null || (Array.isArray(error) ? error.length > 0 : Boolean(error))
const save = async (): Promise<void> => {
if (!saveAction || recordId === undefined) return
const formData = new FormData()
if (recordIdField) formData.set(recordIdField, String(recordId))
formData.set('field', 'phones')
for (const row of rowsRef.current) {
formData.append(name, row.value)
}
setIsPending(true)
setSaveError(null)
try {
const result = await saveAction({}, formData)
setIsPending(false)
if (result.status === 'success') {
setIsEditing(false)
router.refresh()
return
}
const message =
result.fieldErrors?.phones?.[0] ??
result.fieldErrors?.[name]?.[0] ??
result.fieldErrors?.form?.[0] ??
result.message
const refusal = message ?? 'Não foi possível salvar.'
setSaveError(refusal)
toast.error(refusal)
} catch {
setIsPending(false)
const message = 'Não foi possível salvar. Tente novamente.'
setSaveError(message)
toast.error(message)
}
}
const rowInputs = rows.map((row, index) => (
<div key={row.key} className="flex items-center gap-1.5">
<FormattedInput
name={name}
inputMode="tel"
autoComplete="tel"
aria-label={`Celular ${index + 1}`}
aria-invalid={hasError}
aria-describedby={hasError ? `${errorId}` : undefined}
value={displayValue(row.value)}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
className={campaignInlineInputClassName}
onChange={(event) => updateRow(row.key, event.currentTarget.value)}
/>
{rows.length > 1 ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label="Mover para cima (torna principal antes dos demais)"
disabled={index === 0}
onClick={() => moveRow(index, -1)}
>
<ArrowUpIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label="Mover para baixo"
disabled={index === rows.length - 1}
onClick={() => moveRow(index, 1)}
>
<ArrowDownIcon className="size-4" aria-hidden="true" />
</Button>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label={`Remover celular ${index + 1}`}
disabled={rows.length <= minRows}
onClick={() => removeRow(row.key)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
</Button>
</div>
))
const addButton = (
<Button
type="button"
variant="ghost"
className="h-9 w-fit shrink-0 justify-start px-2 text-sm text-muted-foreground"
aria-label="Adicionar telefone"
onClick={addRow}
>
<PlusIcon className="size-4" aria-hidden="true" />
Adicionar telefone
</Button>
)
if (saveAction && !isEditing) {
return (
<div className={className}>
<ul className="flex flex-col gap-1.5" aria-label={label}>
{(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => (
<li key={row.key} className="flex items-center gap-1.5 text-sm tabular-nums">
<span className="w-6 text-right text-muted-foreground">{index + 1}.</span>
<span className="min-w-40">
{row.value ? formatBrazilianPhoneInput(row.value) : '—'}
</span>
</li>
))}
</ul>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 size-11"
aria-label={`Editar ${label.toLowerCase()}`}
onClick={() => {
setIsEditing(true)
setSaveError(null)
}}
>
<span className="sr-only">Editar</span>
<PlusIcon className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
return (
<div className={className}>
<FieldLabel>{label}</FieldLabel>
<div className="flex flex-col gap-2">
{rowInputs}
{addButton}
</div>
{hasError ? (
<FieldError id={errorId}>
{saveError ?? (Array.isArray(error) ? error[0] : error)}
</FieldError>
) : null}
{saveAction ? (
<div className="mt-2 flex items-center gap-2">
<Button type="button" disabled={isPending} onClick={() => void save()}>
{saveLabel}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => {
setIsEditing(false)
setSaveError(null)
}}
>
{cancelLabel}
</Button>
{isPending ? <Spinner className="size-4" aria-label="Salvando telefones" /> : null}
</div>
) : null}
</div>
)
}

View file

@ -2,19 +2,21 @@
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
type StateDeputyContactSectionProps = {
stateDeputyId: number
name: string
email: string | null
phone: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
}
export const StateDeputyContactSection = ({
stateDeputyId,
name,
email,
phone,
phones,
}: StateDeputyContactSectionProps) => (
<section aria-labelledby="state-deputy-contact-title" className="flex flex-col gap-3">
<h2 id="state-deputy-contact-title" className="text-base font-medium">
@ -31,6 +33,7 @@ export const StateDeputyContactSection = ({
value={name}
label="Nome"
formAction={updateStateDeputyContactFormAction}
href={`/campanha/dobradinhas/${stateDeputyId}`}
/>
</dd>
</div>
@ -44,19 +47,21 @@ export const StateDeputyContactSection = ({
value={email}
label="E-mail"
formAction={updateStateDeputyContactFormAction}
readBehavior="copy"
/>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Telefone</dt>
<dt className="text-sm text-muted-foreground">
Telefones <span className="font-normal">(o primeiro é o principal)</span>
</dt>
<dd>
<CampaignInlineEditableCell
<PhonesFieldEditor
defaultValues={phones}
label="Telefones"
saveAction={updateStateDeputyContactFormAction}
recordId={stateDeputyId}
recordIdField="stateDeputyId"
field="phone"
value={phone}
label="Telefone"
formAction={updateStateDeputyContactFormAction}
/>
</dd>
</div>

View file

@ -5,10 +5,10 @@ import { useActionState, useState } from 'react'
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
@ -118,7 +117,6 @@ export const SupporterForm = ({
})
const nameField = errorProps(state.fieldErrors, 'name')
const phoneField = errorProps(state.fieldErrors, 'phone')
const emailField = errorProps(state.fieldErrors, 'email')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
@ -140,7 +138,6 @@ export const SupporterForm = ({
<Input
id="supporter-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
@ -153,25 +150,12 @@ export const SupporterForm = ({
) : null}
</Field>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="supporter-phone">Celular *</FieldLabel>
<FormattedInput
id="supporter-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="supporter-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal)"
error={fieldError(state.fieldErrors, 'phones')}
/>
<Field data-invalid={emailField.invalid}>
<FieldLabel htmlFor="supporter-email">E-mail</FieldLabel>

View file

@ -176,6 +176,24 @@ export const repeatedFormTexts = (formData: FormData, field: string): string[] =
return values
}
/**
* The ficha's phone list from the repeatable editor: order is preserved (=
* priority, first = primary) and empty values are dropped. Duplicates are NOT
* dropped here — the zod `contactPhonesSchema` refine reports them so the
* form can show the "Telefone repetido" refusal instead of silently merging.
*/
export const repeatedPhoneFormValues = (formData: FormData, field: string): string[] => {
const values: string[] = []
for (const value of formData.getAll(field)) {
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
const trimmed = value.trim()
if (trimmed) values.push(trimmed)
}
return values
}
export const checkboxFormValue = (formData: FormData, field: string): boolean => {
const entry = formEntry(formData, field)
if (!entry.present) return false

View file

@ -4,6 +4,9 @@ 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 =
@ -61,3 +64,35 @@ 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)
}

View file

@ -1,4 +1,5 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { z } from 'zod'
import {
@ -44,6 +45,20 @@ const nullableContactPhone = contactPhoneInput.transform((value) =>
value === '' || value === undefined ? null : value,
)
/**
* The ficha's phone list: valid mobiles, never the same number twice within
* the SAME person (C112 — the mesa blocks it at the form); sharing a number
* BETWEEN persons stays free (C111). Order = priority (first = primary).
*/
export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones, context) => {
if (new Set(phones).size !== phones.length) {
context.addIssue({
code: 'custom',
message: BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
})
}
})
/** Per-field Contact write shared by campaign person joins. */
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
@ -61,6 +76,11 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
field: z.literal('phone'),
phone: nullableContactPhone,
}),
z.object({
id: positiveRelationshipId,
field: z.literal('phones'),
phones: contactPhonesSchema,
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>

View file

@ -1,8 +1,7 @@
import { z } from 'zod'
import { contactFieldUpdateSchema } from '@/lib/schemas/contact'
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
import {
brazilianMobile,
optionalPersistedEmail,
positiveRelationshipId,
trimmedNullableText,
@ -91,7 +90,7 @@ export const leadershipCreateSchema = z.object({
organizations: organizationsArraySchema.optional(),
stateDeputies: stateDeputiesArraySchema.optional(),
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
email: optionalPersistedEmail,
gender: z.enum(leadershipGenders).optional(),
exclusive: z.boolean().default(true),
@ -113,7 +112,7 @@ export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpd
const leadershipWizardFieldsSchema = z.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
email: optionalPersistedEmail,
exclusive: z.boolean().default(true),
supportStatus: z.enum(leadershipSupportStatuses).default('a_abordar'),

View file

@ -1,8 +1,8 @@
import { z } from 'zod'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { contactPhonesSchema } from '@/lib/schemas/contact'
import {
brazilianMobile,
optionalPersistedEmail,
positiveRelationshipId,
trimmedNullableText,
@ -70,7 +70,7 @@ const optionalBahiaCity = z
export const supporterCreateSchema = z
.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
email: optionalPersistedEmail,
city: optionalBahiaCity,
municipality: positiveRelationshipId.optional(),
@ -108,7 +108,7 @@ export const supporterImportConfirmSchema = z.object({
export const leaderSupporterCreateSchema = z.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
city: optionalBahiaCity,
municipality: positiveRelationshipId,
consentAccepted: z.literal(true, {

View file

@ -4,6 +4,8 @@ import type { SupportStatus } from '@/lib/schemas/leadership'
export type WizardLeadershipTileViewModel = {
id: number
name: string
/** Every number of the ficha, order = priority (C112). */
phones: string[]
phone: string | null
email: string | null
supportStatus: SupportStatus | null

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// Hand-edited on top of the generated statements: the generator re-emitted
// `campaign_user.contact_id` / `leadership_rels.campaign_user_id` because the
// 20260809_204728 snapshot (.json) does not reflect its hand-written SQL —
// those columns already exist in every environment (C99/C100). Kept here: the
// `contact_phones` join table (order = priority, index on `value`), a backfill
// of the existing single `phone` into `phones[0]` BEFORE the column drops, and
// the drop of `contact.phone` (+ its index). Production holds real PII: the
// backfill is lossless, the drop happens only after it.
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "contact_phones" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"value" varchar
);
ALTER TABLE "contact_phones" ADD CONSTRAINT "contact_phones_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "contact_phones_order_idx" ON "contact_phones" USING btree ("_order");
CREATE INDEX "contact_phones_parent_id_idx" ON "contact_phones" USING btree ("_parent_id");
CREATE INDEX "contact_phones_value_idx" ON "contact_phones" USING btree ("value");
INSERT INTO "contact_phones" ("id", "_parent_id", "_order", "value")
SELECT gen_random_uuid()::text, "id", 0, "phone" FROM "contact" WHERE "phone" IS NOT NULL;
DROP INDEX "contact_phone_idx";
ALTER TABLE "contact" DROP COLUMN "phone";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Rollback is lossy BY DESIGN: only the primary (`_order` 0) is restored to
// `contact.phone` — every appended number is discarded. Rollbacks are
// best-effort here (C111-era policy); the forward path is lossless.
await db.execute(sql`
ALTER TABLE "contact" ADD COLUMN "phone" varchar;
UPDATE "contact" SET "phone" = "contact_phones"."value"
FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."_order" = 0;
CREATE INDEX "contact_phone_idx" ON "contact" USING btree ("phone");
ALTER TABLE "contact_phones" DISABLE ROW LEVEL SECURITY;
DROP TABLE "contact_phones" CASCADE;`)
}

View file

@ -47,6 +47,7 @@ import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors';
import * as migration_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day';
import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array';
export const migrations = [
{
@ -294,4 +295,9 @@ export const migrations = [
down: migration_20260810_010844_add_activity_all_day.down,
name: '20260810_010844_add_activity_all_day',
},
{
up: migration_20260811_111828_add_contact_phones_array.up,
down: migration_20260811_111828_add_contact_phones_array.down,
name: '20260811_111828_add_contact_phones_array'
},
];

View file

@ -298,7 +298,12 @@ export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
phones?:
| {
value?: string | null;
id?: string | null;
}[]
| null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
@ -1977,7 +1982,12 @@ export interface PetitionSelect<T extends boolean = true> {
export interface ContactSelect<T extends boolean = true> {
name?: T;
email?: T;
phone?: T;
phones?:
| T
| {
value?: T;
id?: T;
};
gender?: T;
state?: T;
city?: T;

View file

@ -42,7 +42,7 @@ export const searchActivityLeadershipOptions = async (
{
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phone': { contains: digits || trimmed } },
{ 'contact.phones.value': { contains: digits || trimmed } },
],
},
],

View file

@ -96,7 +96,7 @@ const searchStateDeputyResponsibles = async (
where: {
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phone': { contains: digits || trimmed } },
{ 'contact.phones.value': { contains: digits || trimmed } },
],
},
depth: 1,

View file

@ -1,5 +1,6 @@
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
import {
activityResponsibleTypeLabels,
@ -287,7 +288,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
? {
id: task.responsible.id,
name: task.responsible.name,
phone: task.responsible.phone ?? null,
phone: primaryPhoneOf(task.responsible.phones),
}
: null,
due: task.due ?? null,

View file

@ -2,6 +2,9 @@ import 'server-only'
import type { Payload } from 'payload'
import type { Contact } from '@/payload-types'
import { phoneValuesOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import {
advisorListHrefForPage,
@ -35,6 +38,10 @@ export type AdvisorRowViewModel = AdvisorAccountViewModel & {
/** The detail page renders real names and links, so it keeps the labels. */
export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
/** The advisor's normalized ficha (C99) — target of the ficha phones editor. */
contactID: number | null
/** Every number of the ficha, order = priority (C112) — primary first. */
fichaPhones: string[]
municipalities: AdvisorMunicipalityViewModel[]
}
@ -222,6 +229,7 @@ export const loadAdvisorDetail = async (
email?: string | null
phone?: string | null
role: string
contact?: number | Contact | null
}
try {
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
@ -229,7 +237,7 @@ export const loadAdvisorDetail = async (
collection: 'campaignUser',
id: advisorId,
depth: 0,
select: { name: true, email: true, phone: true, role: true },
select: { name: true, email: true, phone: true, role: true, contact: true },
overrideAccess: true,
})
} catch {
@ -238,12 +246,30 @@ export const loadAdvisorDetail = async (
if (advisor.role !== 'advisor') return null
const contactID = typeof advisor.contact === 'number' ? advisor.contact : null
const fichaPhones =
contactID === null
? []
: await payload
.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { phones: { value: true } },
// Intentional admin bypass: same staff-scoped read rationale.
overrideAccess: true,
})
.then((contact) => phoneValuesOf(contact.phones))
.catch(() => [])
const municipalitiesByAdvisor = await municipalitiesByAdvisorIds(payload, [advisor.id])
return {
id: advisor.id,
name: advisor.name,
email: advisor.email ?? null,
phone: advisor.phone ?? null,
contactID,
fichaPhones,
municipalities: municipalitiesByAdvisor.get(advisor.id) ?? [],
}
}

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE } from '@/lib/campaignInviteClient'
import { primaryPhoneOf } from '@/lib/phone'
import { requireRelationshipId } from '@/lib/relationship'
import { campaignInviteCreateSchema, type CampaignInviteCreateInput } from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
@ -78,14 +79,15 @@ export const createCampaignInviteForActor = async (
overrideAccess: true,
req,
})
if (!contact.phone) {
const primaryPhone = primaryPhoneOf(contact.phones)
if (!primaryPhone) {
throw new Error('Cadastre o celular da liderança antes de gerar o convite.')
}
const inviteUrl = `${inviteBaseURL}/campanha/convite/${generated.token}`
return {
inviteUrl,
whatsappUrl: buildCampaignInviteWhatsAppLink({
phone: contact.phone,
phone: primaryPhone,
recipientName: contact.name,
senderName: currentActor.name,
inviteUrl,

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import type { Consent } from '@/payload-types'
import { getLeadershipConsent } from '@/utilities/campaignConsent'
@ -73,7 +74,7 @@ export const getCampaignInvitePageData = async (
depth: 0,
select: {
name: true,
phone: true,
phones: { value: true },
email: true,
gender: true,
},
@ -89,7 +90,7 @@ export const getCampaignInvitePageData = async (
kind: invite.kind,
profile: {
name: contact.name,
phone: contact.phone ?? '',
phone: primaryPhoneOf(contact.phones) ?? '',
email: contact.email ?? null,
gender: contact.gender ?? null,
},

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf, reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import {
campaignInviteAutofillSchema,
@ -9,7 +10,7 @@ import {
type CampaignInviteAutofillInput,
type CampaignInviteLoginInput,
} from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
import type { CampaignUser, Contact } from '@/payload-types'
import { requireLeadershipConsent, resolveInviteConsent } from '@/utilities/campaignConsent'
import {
acquireCampaignInviteAccountLocks,
@ -32,9 +33,12 @@ type ProfileInput = {
gender?: 'feminino' | 'masculino' | 'outro' | 'nao_informado' | null
}
const profileContactData = (data: ProfileInput) => ({
const profileContactData = (data: ProfileInput, originalPhones: Contact['phones']) => ({
name: data.name,
phone: data.phone,
// The typed phone becomes the PRIMARY of the anchored ficha; every number
// the ficha already had is kept (an earlier occurrence of the typed phone
// moves, never duplicates) — the person's other numbers survive the invite.
phones: reorderWithPrimaryPhone(originalPhones, data.phone).map((value) => ({ value })),
...(data.email !== undefined ? { email: data.email } : {}),
...(data.gender !== undefined ? { gender: data.gender } : {}),
})
@ -123,7 +127,9 @@ export const redeemCampaignInviteAutofillRecord = async (
payload,
req,
contactPhoneLockKeys(
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
[primaryPhoneOf(originalContact.phones), data.phone].filter((phone): phone is string =>
Boolean(phone),
),
),
)
// C111 — the phone is a contact channel, not a unique person identity:
@ -132,7 +138,7 @@ export const redeemCampaignInviteAutofillRecord = async (
await payload.update({
collection: 'contact',
id: contactID,
data: profileContactData(data),
data: profileContactData(data, originalContact.phones),
depth: 0,
overrideAccess: true,
req,
@ -194,7 +200,9 @@ export const redeemCampaignInviteLoginRecord = async (
let account = await findReusableLeadershipAccount(payload, req, leadership)
await acquireCampaignInviteAccountLocks(payload, req, [
...contactPhoneLockKeys(
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
[primaryPhoneOf(originalContact.phones), data.phone].filter((phone): phone is string =>
Boolean(phone),
),
),
...(account?.username ? [`account-username:${account.username}`] : []),
`account-username:${data.phone}`,
@ -224,7 +232,7 @@ export const redeemCampaignInviteLoginRecord = async (
await payload.update({
collection: 'contact',
id: originalContact.id,
data: profileContactData(data),
data: profileContactData(data, originalContact.phones),
depth: 0,
overrideAccess: true,
req,

View file

@ -10,16 +10,19 @@ type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
/**
* Find-or-create the normalized `Contact` ficha for a person, inside the
* caller's transaction. With a phone, the advisory lock is acquired HERE and
* an existing ficha with that phone is reused — but only when the 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 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). Without a phone, a fresh name-only ficha is created. Every "cria ou
* vincula a ficha" path shares one owner for the "BA default + shared-phone"
* policy.
* 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
@ -29,7 +32,7 @@ type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
export const findOrCreateContactByPhone = async ({
payload,
req,
phone,
phones,
name,
email,
city,
@ -37,18 +40,22 @@ export const findOrCreateContactByPhone = async ({
}: {
payload: Payload
req: ContactIdentityRequest
phone: string | null
phones: string[]
name: string
email?: string | null
city?: string | null
gender?: Contact['gender'] | null
}): Promise<{ contactID: number; reused: boolean }> => {
if (phone) {
await acquireContactPhoneLocks(payload, req, [phone])
const primary = phones[0] ?? null
if (primary) {
await acquireContactPhoneLocks(payload, req, phones)
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
// 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,
@ -59,7 +66,24 @@ export const findOrCreateContactByPhone = async ({
})
if (contacts.totalDocs === 1) {
return { contactID: contacts.docs[0]!.id, reused: true }
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 }
}
}
@ -67,7 +91,7 @@ export const findOrCreateContactByPhone = async ({
collection: 'contact',
data: {
name,
phone,
phones: phones.map((value) => ({ value })),
email: email ?? null,
state: 'BA' as Contact['state'],
city: city ?? null,

View file

@ -14,6 +14,7 @@ import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { primaryPhoneOf } from '@/lib/phone'
import { populatedContactName } from '@/lib/relationship'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignUser, Leadership } from '@/payload-types'
@ -21,9 +22,9 @@ import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { buildLeadershipListWhere } from '@/utilities/leadership/leadershipListUrl'
const populatedContactPhone = (contact: unknown): string | null => {
if (typeof contact !== 'object' || contact === null || !('phone' in contact)) return null
const { phone } = contact
return typeof phone === 'string' && phone.length > 0 ? phone : null
if (typeof contact !== 'object' || contact === null || !('phones' in contact)) return null
const phones = (contact as { phones?: unknown }).phones
return Array.isArray(phones) ? primaryPhoneOf(phones as { value?: string | null }[]) : null
}
const municipalityNamesFromLeadership = (doc: Leadership): string[] => {

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { primaryPhoneOf } from '@/lib/phone'
import type { CampaignUser, Contact, Municipality } from '@/payload-types'
import { getEngagedLeaderMunicipalityIds } from '@/utilities/campaignAccess'
import { getSupporterRegistrationConsent } from '@/utilities/campaignConsent'
@ -36,7 +37,7 @@ const toLeaderContactListItem = (supporter: {
return {
id: supporter.id,
name: typeof contact === 'object' && contact ? contact.name : 'Contato',
phone: typeof contact === 'object' && contact ? (contact.phone ?? null) : null,
phone: typeof contact === 'object' && contact ? primaryPhoneOf(contact.phones) : null,
city: typeof contact === 'object' && contact ? (contact.city ?? null) : null,
municipalityName: typeof municipality === 'object' && municipality ? municipality.name : null,
createdAt: supporter.createdAt,

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import type { SupportStatus } from '@/lib/schemas/leadership'
import { isSupportStatus } from '@/lib/schemas/leadership'
@ -42,16 +43,23 @@ export type LeadershipRowViewModel = {
const contactSummary = (
contact: Leadership['contact'],
): { id: number; name: string; phone: string | null; email: string | null } => {
): {
id: number
name: string
phone: string | null
phones: { value: string }[]
email: string | null
} => {
if (typeof contact === 'object' && contact !== null) {
return {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
phones: phoneValuesOf(contact.phones).map((value) => ({ value })),
email: contact.email ?? null,
}
}
return { id: Number(contact), name: 'Contato', phone: null, email: null }
return { id: Number(contact), name: 'Contato', phone: null, phones: [], email: null }
}
const findMunicipalityLeadershipDocs = async (
@ -78,6 +86,9 @@ const toWizardLeadershipTile = (doc: Leadership): WizardLeadershipTileViewModel
return {
id: doc.id,
name: contact.name,
phones: (contact.phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value)),
phone: contact.phone,
email: contact.email,
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
@ -337,6 +348,8 @@ export const loadLeadershipListPageData = async (
}
export type LeadershipDetailViewModel = LeadershipRowViewModel & {
/** Every number of the ficha, order = priority (C112). */
phones: string[]
municipalityIDs: number[]
organizationIDs: number[]
stateDeputyIDs: number[]
@ -372,6 +385,9 @@ export const loadLeadershipDetail = async (
return {
...row,
phones: phoneValuesOf(
doc.contact && typeof doc.contact === 'object' ? doc.contact.phones : null,
),
municipalityIDs: (doc.municipalities ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { isSupportStatus, type SupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
@ -293,7 +294,7 @@ const contactSummary = (contact: Contact | number | null | undefined): ContactSu
return {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
email: contact.email ?? null,
city: contact.city ?? null,
}

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
@ -153,7 +154,9 @@ const anonymizeContact = async (
data: {
name: 'Titular removido',
email: null,
phone: tombstonePhone,
// The tombstone REPLACES every number — the remaining phones are the
// person's PII and must not survive the anonymization (C112).
phones: [{ value: tombstonePhone }],
gender: null,
city: null,
postalCode: null,
@ -300,7 +303,7 @@ export const loadPersonDeleteManifest = async (
contact: {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
email: contact.email ?? null,
},
leaderships: leadershipRows,

View file

@ -1,3 +1,4 @@
import { primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship } from '@/lib/relationship'
import type { Contact, Petition } from '@/payload-types'
import type { ToCSVFunction } from '@payloadcms/plugin-import-export/types'
@ -27,7 +28,7 @@ export const signatureContactToCSV: ToCSVFunction = ({ value, row }) => {
row.contact_name = value.name
row.contact_email = value.email ?? ''
row.contact_phone = value.phone ?? ''
row.contact_phone = primaryPhoneOf(value.phones) ?? ''
row.contact_state = value.state ?? ''
row.contact_city = value.city ?? ''
return undefined

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { CampaignUser, StateDeputy } from '@/payload-types'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
@ -40,7 +41,7 @@ const stateDeputyContactSummary = (
id: contact.id,
name: contact.name,
email: contact.email ?? null,
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
}
}
@ -240,6 +241,8 @@ export type StateDeputySummary = {
export type StateDeputyDetailViewModel = StateDeputySummary & {
email: string | null
phone: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
notes: string | null
municipalities: Array<{ id: number; name: string; slug: string }>
leaderships: Array<{ id: number; name: string }>
@ -300,8 +303,12 @@ export const loadStateDeputyDetail = async (
loadCampaignUserSummaries(payload, user, uniqueRelationshipIds(stateDeputy.advisors)),
])
const contactSummary = stateDeputyContactSummary(stateDeputy.contact)
const contact = stateDeputy.contact
return {
...stateDeputyContactSummary(stateDeputy.contact),
...contactSummary,
phones: phoneValuesOf(typeof contact === 'object' && contact !== null ? contact.phones : null),
id: stateDeputy.id,
slug: stateDeputy.slug,
party: stateDeputy.party ?? null,

View file

@ -1,5 +1,7 @@
import 'server-only'
import { randomUUID } from 'node:crypto'
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
@ -43,6 +45,7 @@ type DrizzleTx = {
}
const CONTACT_TABLE = 'contact'
const CONTACT_PHONES_TABLE = 'contact_phones'
const SUPPORTER_TABLE = 'supporter'
/**
@ -68,13 +71,15 @@ const SUPPORTER_COLUMNS = [
'updatedAt',
] as const
/** The `Contact.phones` join table (C112): order = priority, value = the number. */
const CONTACT_PHONES_COLUMNS = ['_order', '_parentID', 'value'] as const
const buildContactRows = (
rows: SupporterImportBulkRow[],
now: string,
): Array<Record<string, unknown>> =>
rows.map((row) => ({
name: row.nome,
phone: row.telefone,
email: null,
state: 'BA',
city: row.municipio ?? null,
@ -128,6 +133,12 @@ const asContactID = (row: Record<string, unknown>): number | null => {
return Number.isSafeInteger(parsed) ? parsed : null
}
const asCreatedContactID = (row: Record<string, unknown>): number | null => {
const value = row.id
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(parsed) ? parsed : null
}
/**
* Bulk-insert contacts and municipality-less supporters for a CSV import inside the
* caller's Payload transaction. Phone advisory locks MUST already be held for
@ -159,8 +170,10 @@ export const bulkInsertSupporterImport = async (args: {
const tables = getDrizzleTables(payload)
const contactTable = requireTable(tables, CONTACT_TABLE)
const contactPhonesTable = requireTable(tables, CONTACT_PHONES_TABLE)
const supporterTable = requireTable(tables, SUPPORTER_TABLE)
assertDrizzleColumns(supporterTable, SUPPORTER_TABLE, SUPPORTER_COLUMNS)
assertDrizzleColumns(contactPhonesTable, CONTACT_PHONES_TABLE, CONTACT_PHONES_COLUMNS)
const database = await getPostgresTransactionDatabase(payload, req)
const tx = database as unknown as DrizzleTx
@ -172,10 +185,12 @@ export const bulkInsertSupporterImport = async (args: {
for (const phoneBatch of chunk(phones, INSERT_CHUNK_SIZE)) {
const existingContactRows = drizzleResultRows(
await tx.execute(
sql`SELECT "id", "phone" FROM "contact" WHERE "phone" IN (${sql.join(
phoneBatch.map((phone) => sql`${phone}`),
sql`, `,
)})`,
sql`SELECT "contact"."id" AS "id", "contact_phones"."value" AS "phone" FROM "contact"
JOIN "contact_phones" ON "contact_phones"."_parent_id" = "contact"."id"
WHERE "contact_phones"."value" IN (${sql.join(
phoneBatch.map((phone) => sql`${phone}`),
sql`, `,
)})`,
),
)
for (const row of existingContactRows) {
@ -205,9 +220,18 @@ export const bulkInsertSupporterImport = async (args: {
const newRows = [...newContactByPhone.values()]
for (const batch of chunk(newRows, INSERT_CHUNK_SIZE)) {
const created = await tx.insert(contactTable).values(buildContactRows(batch, now)).returning()
for (const row of created) {
const inserted = asPhoneKeyedRow(row)
if (inserted) contactIdByPhone.set(inserted.phone, inserted.id)
// The phones join-table rows follow the inserted contacts: each new
// ficha starts with its CSV number as the single (primary) phone.
const phoneRows: Array<Record<string, unknown>> = []
created.forEach((row, index) => {
const insertedID = asCreatedContactID(row)
const telefone = batch[index]?.telefone
if (insertedID === null || !telefone) return
contactIdByPhone.set(telefone, insertedID)
phoneRows.push({ id: randomUUID(), _parentID: insertedID, _order: 0, value: telefone })
})
if (phoneRows.length > 0) {
await tx.insert(contactPhonesTable).values(phoneRows)
}
}
}

View file

@ -40,9 +40,10 @@ const buildPayloadSearchWhere = (terms: SupporterSearchTerms): Where => {
]
if (terms.normalizedPhone) {
searchFilters.push({ 'contact.phone': { equals: terms.normalizedPhone } })
// Matches ANY of the ficha's numbers (C112).
searchFilters.push({ 'contact.phones.value': { equals: terms.normalizedPhone } })
} else if (terms.phoneDigits) {
searchFilters.push({ 'contact.phone': { contains: terms.phoneDigits } })
searchFilters.push({ 'contact.phones.value': { contains: terms.phoneDigits } })
}
return { or: searchFilters }

View file

@ -20,9 +20,14 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType<typeof
]
if (terms.normalizedPhone) {
searchTerms.push(sql`"contact"."phone" = ${terms.normalizedPhone}`)
// Matches ANY of the ficha's numbers (C112) — EXISTS over the join table.
searchTerms.push(
sql`EXISTS (SELECT 1 FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."value" = ${terms.normalizedPhone})`,
)
} else if (terms.phoneDigits) {
searchTerms.push(sql`"contact"."phone" ILIKE ${`%${terms.phoneDigits}%`}`)
searchTerms.push(
sql`EXISTS (SELECT 1 FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."value" ILIKE ${`%${terms.phoneDigits}%`})`,
)
}
return sql`(${sql.join(searchTerms, sql` OR `)})`

View file

@ -1,5 +1,5 @@
import { territoryForCity } from '@/lib/bahiaTerritories'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { formatBrazilianPhoneInput, phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship } from '@/lib/relationship'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { CampaignUser, Contact, Municipality, Supporter } from '@/payload-types'
@ -27,6 +27,8 @@ export type SupporterDetailViewModel = {
name: string
phone: string
phoneDisplay: string
/** Every number of the person, order = priority (primary first). */
phones: string[]
email: string | null
city: string | null
territory: string | null
@ -57,7 +59,7 @@ export const toSupporterListItemViewModel = (
return {
id: supporter.id,
name: contact?.name ?? 'Contato sem nome',
phone: contact?.phone ?? '',
phone: primaryPhoneOf(contact?.phones) ?? '',
email: contact?.email ?? null,
city: contact?.city ?? null,
municipalityName: municipality?.name ?? null,
@ -73,13 +75,15 @@ export const toSupporterDetailViewModel = (supporter: Supporter): SupporterDetai
? supporter.createdBy
: null
const city = contact?.city ?? null
const phone = contact?.phone ?? ''
const phones = phoneValuesOf(contact?.phones)
const phone = phones[0] ?? ''
return {
id: supporter.id,
name: contact?.name ?? 'Contato sem nome',
phone,
phoneDisplay: phone ? formatBrazilianPhoneInput(phone) : '',
phones,
email: contact?.email ?? null,
city,
territory: city ? (territoryForCity(city) ?? null) : null,

View file

@ -357,7 +357,7 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
}).toPass({ timeout: 15_000 })
await page.getByLabel('Nome').fill(`Liderança B70 ${suffix}`)
await page.getByLabel('Celular').fill(`719${suffix}`)
await page.getByRole('textbox', { name: 'Celular 1' }).fill(`719${suffix}`)
await page.getByRole('button', { name: 'Salvar' }).click()
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({

View file

@ -644,7 +644,7 @@ test.describe('Municípios — jornadas por papel', () => {
collection: 'contact',
data: {
name: fixtures.value('Contato Liderança'),
phone: leaderPhone,
phones: [{ value: leaderPhone }],
state: 'BA',
city: administered.name,
},
@ -705,7 +705,7 @@ test.describe('Municípios — jornadas por papel', () => {
const supporterName = fixtures.value('Apoiador Liderança')
const supporterPhone = fixtures.phone()
await page.getByLabel('Nome *').fill(supporterName)
await page.getByLabel('Celular *').fill(supporterPhone)
await page.getByRole('textbox', { name: 'Celular 1' }).fill(supporterPhone)
// Radix checkbox: a pre-hydration click is a silent no-op (B13/B17 flake).
await checkRadixWhenHydrated(page, 'A pessoa autorizou o cadastro *')
await page.getByRole('button', { name: 'Cadastrar contato' }).click()

View file

@ -191,7 +191,7 @@ export class CampaignE2EOwnership {
collection: 'contact',
data: {
name: contactName,
phone: this.phone(),
phones: [{ value: this.phone() }],
state: 'BA',
city: municipalities[0]?.name ?? 'Salvador',
},

View file

@ -62,7 +62,9 @@ type EditorUserInput = {
}
type ConsentInput = Partial<Pick<Consent, 'key' | 'text'>>
type ContactInput = Partial<
Pick<Contact, 'name' | 'email' | 'phone' | 'gender' | 'state' | 'city' | 'postalCode'>
Pick<Contact, 'name' | 'email' | 'gender' | 'state' | 'city' | 'postalCode'> & {
phones: Array<{ value: string }>
}
>
type LeadershipInput = Partial<
Pick<
@ -601,7 +603,7 @@ export class CampaignFixtures {
or: [
{ name: { contains: this.runID } },
{ email: { contains: this.runID } },
{ phone: { contains: this.runID } },
{ 'phones.value': { contains: this.runID } },
],
},
depth: 0,
@ -713,11 +715,12 @@ export class CampaignFixtures {
}
async createContact(input: ContactInput = {}): Promise<Contact & { phone: string }> {
const phone = input.phones?.[0]?.value ?? this.phone()
const contact = await this.rootPayload.create({
collection: 'contact',
data: {
name: this.value('Contato'),
phone: this.phone(),
phones: [{ value: phone }],
state: 'BA',
city: 'Salvador',
...input,
@ -725,12 +728,13 @@ export class CampaignFixtures {
depth: 0,
})
this.own('contact', contact)
// Contact.phone is optional at the collection level (name-only imports), but
// every fixture contact carries one — narrow so specs can rely on it.
if (typeof contact.phone !== 'string') {
// The fixture contract is "one primary phone": every fixture contact carries
// one — narrow so specs can rely on it (C112 keeps the array under the hood).
const primary = contact.phones?.[0]?.value
if (typeof primary !== 'string') {
throw new Error('Fixture contacts must always carry a phone.')
}
return { ...contact, phone: contact.phone }
return { ...contact, phone: primary }
}
/** Assign advisors to a seeded municipality (tracked for reset on cleanup). */
@ -945,7 +949,7 @@ export class CampaignFixtures {
for (const contact of contacts.docs) {
if (
this.owned.contact.has(contact.id) ||
this.hasMarker(contact.name, contact.email, contact.phone)
this.hasMarker(contact.name, contact.email, contact.phones?.[0]?.value ?? null)
) {
this.own('contact', contact)
}

View file

@ -47,7 +47,7 @@ describe('campaignUser → Contact identity (C99)', () => {
})
expect(contact).toMatchObject({
name: 'Maria Assessora',
phone: user.phone,
phones: [{ value: user.phone }],
state: 'BA',
})
})
@ -84,7 +84,7 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: 'Sem Telefone', phone: null, state: 'BA' })
expect(contact).toMatchObject({ name: 'Sem Telefone', phones: [], state: 'BA' })
})
it('never copies placeholder e-mails to the ficha', async () => {
@ -174,7 +174,10 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: 'Nome Atualizado', phone: nextPhone })
expect(contact).toMatchObject({
name: 'Nome Atualizado',
phones: expect.arrayContaining([expect.objectContaining({ value: nextPhone })]),
})
})
it('shares the phone with another ficha when the account phone is updated (C111)', async () => {
@ -200,11 +203,13 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(other.phone)
expect(contact.phones?.map((entry) => entry.value)).toEqual(
expect.arrayContaining([user.phone, other.phone]),
)
const othersWithPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: other.phone } },
where: { 'phones.value': { equals: other.phone } },
depth: 0,
limit: 2,
pagination: false,
@ -244,13 +249,22 @@ describe('campaignUser → Contact identity (C99)', () => {
it('creates a fresh ficha when the phone already belongs to two fichas (C111)', async () => {
const fixtures = campaignFixtures()
const phone = fixtures.phone()
const sharedNameA = fixtures.value('Compartilhado A')
const sharedNameB = fixtures.value('Compartilhado B')
// Two people sharing one phone is a legitimate C111 state; it is planted
// directly because the collection hook no longer enforces uniqueness.
await payload.db.drizzle.execute(
sql.raw(`
INSERT INTO "contact" ("name", "email", "phone", "state", "city")
VALUES ('${fixtures.value('Compartilhado A')}', '', '${phone}', 'BA', ''),
('${fixtures.value('Compartilhado B')}', '', '${phone}', 'BA', '')
INSERT INTO "contact" ("name", "state", "city")
VALUES ('${sharedNameA}', 'BA', ''),
('${sharedNameB}', 'BA', '')
`),
)
await payload.db.drizzle.execute(
sql.raw(`
INSERT INTO "contact_phones" ("id", "_parent_id", "_order", "value")
SELECT gen_random_uuid()::text, "id", 0, '${phone}' FROM "contact" WHERE "name" IN (
'${sharedNameA}', '${sharedNameB}')
`),
)
@ -265,11 +279,11 @@ describe('campaignUser → Contact identity (C99)', () => {
// The identity of the person being created is the fresh ficha — never a
// guess among the existing ones.
expect(contact.name).toBe(user.name)
expect(contact.phone).toBe(phone)
expect(contact.phones?.[0]?.value).toBe(phone)
const allWithPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 0,
pagination: false,
@ -323,7 +337,7 @@ describe('campaignUser → Contact identity (C99)', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
pagination: false,
@ -353,7 +367,7 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(user.phone)
expect(contact.phones?.[0]?.value).toBe(user.phone)
})
it('does not rewrite the ficha on a non-identity update (username)', async () => {
@ -377,7 +391,7 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: user.name, phone: user.phone })
expect(contact).toMatchObject({ name: user.name, phones: [{ value: user.phone }] })
})
it('swaps a placeholder e-mail for the real one on the ficha in update', async () => {

View file

@ -222,7 +222,7 @@ describe('campaign integration fixtures', () => {
collection: 'contact',
data: {
name: fixtures.value('Contato criado pelo servidor E2E'),
phone: fixtures.phone(),
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
@ -286,14 +286,14 @@ describe('campaign integration fixtures', () => {
const findOrCreateSentinelContact = async (name: string, phone: string) => {
const existing = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
})
if (existing.docs[0]) return existing.docs[0]
return payload.create({
collection: 'contact',
data: { name, phone, state: 'BA', city: 'Salvador' },
data: { name, phones: [{ value: phone }], state: 'BA', city: 'Salvador' },
depth: 0,
})
}

View file

@ -194,7 +194,6 @@ const createInviteLeadershipGraph = async (
) => {
const contact = await campaignFixtures().createContact({
name: campaignFixtures().value('Liderança convite'),
phone: campaignFixtures().phone(),
})
const leadership = await campaignFixtures().createLeadership({
contact,
@ -960,7 +959,7 @@ describe('campaign invite domain', () => {
})
expect(updatedContact).toMatchObject({
name: 'Maria Confirmada',
phone: submission.phone,
phones: expect.arrayContaining([expect.objectContaining({ value: submission.phone })]),
email: 'confirmada@example.com',
gender: 'feminino',
})
@ -1063,10 +1062,14 @@ describe('campaign invite domain', () => {
await expect(
payload.findByID({ collection: 'contact', id: target.contact.id, depth: 0 }),
).resolves.toMatchObject({ phone: owner.contact.phone })
).resolves.toMatchObject({
phones: expect.arrayContaining([expect.objectContaining({ value: owner.contact.phone })]),
})
await expect(
payload.findByID({ collection: 'contact', id: owner.contact.id, depth: 0 }),
).resolves.toMatchObject({ phone: owner.contact.phone })
).resolves.toMatchObject({
phones: expect.arrayContaining([expect.objectContaining({ value: owner.contact.phone })]),
})
},
)
@ -1114,7 +1117,11 @@ describe('campaign invite domain', () => {
payload.findByID({ collection: 'contact', id, depth: 0 }),
),
)
expect(contacts.every(({ phone }) => phone === targetPhone)).toBe(true)
expect(
contacts.every((contact) =>
(contact.phones ?? []).some((entry) => entry.value === targetPhone),
),
).toBe(true)
},
)
@ -1156,8 +1163,12 @@ describe('campaign invite domain', () => {
payload.findByID({ collection: 'contact', id: first.contact.id, depth: 0 }),
payload.findByID({ collection: 'contact', id: second.contact.id, depth: 0 }),
])
expect(firstContact.phone).toBe(second.contact.phone)
expect(secondContact.phone).toBe(first.contact.phone)
expect(firstContact.phones?.map((entry) => entry.value)).toEqual(
expect.arrayContaining([second.contact.phone]),
)
expect(secondContact.phones?.map((entry) => entry.value)).toEqual(
expect.arrayContaining([first.contact.phone]),
)
},
)

View file

@ -126,7 +126,7 @@ const createPublicInviteScenario = async (
collection: 'contact',
data: {
name: 'Maria da Conceição',
phone: campaignFixtures().phone(),
phones: [{ value: campaignFixtures().phone() }],
email: 'maria@example.com',
state: 'BA',
city: 'Salvador',
@ -216,7 +216,7 @@ describe('campaign invite UI contracts', () => {
kind: 'autopreenchimento',
profile: {
name: fixture.contact.name,
phone: fixture.contact.phone,
phone: fixture.contact.phones?.[0]?.value ?? '',
email: fixture.contact.email,
gender: fixture.contact.gender,
},
@ -354,7 +354,7 @@ describe('campaign invite UI contracts', () => {
const fixture = await createPublicInviteScenario('login')
const formData = new FormData()
formData.set('name', fixture.contact.name)
formData.set('phone', fixture.contact.phone!)
formData.set('phone', fixture.contact.phones?.[0]?.value ?? '')
formData.set('email', fixture.contact.email ?? '')
formData.set('password', 'senha-segura')
formData.set('passwordConfirmation', 'senha-diferente')
@ -377,7 +377,7 @@ describe('campaign invite UI contracts', () => {
const fixture = await createPublicInviteScenario('login')
const formData = new FormData()
formData.set('name', fixture.contact.name)
formData.set('phone', fixture.contact.phone!)
formData.set('phone', fixture.contact.phones?.[0]?.value ?? '')
formData.set('email', fixture.contact.email ?? '')
formData.set('password', ' senha-segura ')
formData.set('passwordConfirmation', 'senha-segura')
@ -401,7 +401,7 @@ describe('campaign invite UI contracts', () => {
const fixture = await createPublicInviteScenario('autopreenchimento')
const formData = new FormData()
formData.set('name', 'Maria Atualizada')
formData.set('phone', fixture.contact.phone!)
formData.set('phone', fixture.contact.phones?.[0]?.value ?? '')
formData.set('email', fixture.contact.email ?? '')
formData.set('gender', 'feminino')
formData.set('consentAccepted', 'on')

View file

@ -44,7 +44,7 @@ describe('campaign leadership domain', () => {
expect(Contact.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'email', required: false }),
expect.objectContaining({ name: 'phone', index: true }),
expect.objectContaining({ name: 'phones', type: 'array' }),
expect.objectContaining({ name: 'gender', type: 'select' }),
]),
)
@ -63,14 +63,14 @@ describe('campaign leadership domain', () => {
const parsed = leadershipCreateSchema.parse({
municipalities: [1, 1, 2],
name: 'Maria da Silva',
phone: '+55 (71) 99999-0000',
phones: ['+55 (71) 99999-0000'],
supportStatus: 'engajado',
createdBy: 999,
user: 999,
consent: 999,
})
expect(parsed.phone).toBe('71999990000')
expect(parsed.phones).toEqual(['71999990000'])
expect(parsed.municipalities).toEqual([1, 2])
expect(Object.hasOwn(parsed, 'createdBy')).toBe(false)
expect(Object.hasOwn(parsed, 'user')).toBe(false)
@ -79,7 +79,7 @@ describe('campaign leadership domain', () => {
leadershipCreateSchema.parse({
municipalities: [],
name: 'Sem município',
phone: '71999990000',
phones: ['71999990000'],
}),
).toThrow()
const update = leadershipInternalUpdateSchema.parse({
@ -124,7 +124,7 @@ describe('campaign leadership domain', () => {
const first = await createLeadershipRecord(payload, coordinator, {
municipalities: [firstMunicipality.id],
name: 'Maria de Jesus',
phone: `+55 ${phone}`,
phones: [`+55 ${phone}`],
email: '',
gender: 'feminino',
supportStatus: 'engajado',
@ -135,7 +135,7 @@ describe('campaign leadership domain', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
pagination: false,
depth: 0,
})
@ -149,7 +149,7 @@ describe('campaign leadership domain', () => {
createLeadershipRecord(payload, coordinator, {
municipalities: [secondMunicipality.id],
name: 'Nome que não sobrescreve contato existente',
phone,
phones: [phone],
supportStatus: 'a_abordar',
}),
).rejects.toThrow(DUPLICATE_LEADERSHIP_MESSAGE)
@ -159,12 +159,15 @@ describe('campaign leadership domain', () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const phone = campaignFixtures().phone()
const contact = await campaignFixtures().createContact({ name: 'Contato Existente', phone })
const contact = await campaignFixtures().createContact({
name: 'Contato Existente',
phones: [{ value: phone }],
})
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Nome que não sobrescreve contato existente',
phone: `+55 ${phone}`,
phones: [`+55 ${phone}`],
supportStatus: 'engajado',
})
@ -209,7 +212,7 @@ describe('campaign leadership domain', () => {
const first = createLeadershipRecord(payload, coordinator, {
municipalities: [firstMunicipality.id],
name: 'Contato Concorrente',
phone,
phones: [phone],
supportStatus: 'engajado',
})
await firstReadReached
@ -230,7 +233,7 @@ describe('campaign leadership domain', () => {
const second = createLeadershipRecord(payload, coordinator, {
municipalities: [secondMunicipality.id],
name: 'Contato Concorrente Duplicado',
phone: `+55 ${phone}`,
phones: [`+55 ${phone}`],
supportStatus: 'a_abordar',
})
@ -267,7 +270,7 @@ describe('campaign leadership domain', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
pagination: false,
depth: 0,
})
@ -288,13 +291,13 @@ describe('campaign leadership domain', () => {
await payload.create({
collection: 'contact',
data: { name: 'Primeiro Contato', phone, state: 'BA', city: 'Salvador' },
data: { name: 'Primeiro Contato', phones: [{ value: phone }], state: 'BA', city: 'Salvador' },
})
// C111 — the phone is a contact channel, not a unique identity.
await payload.create({
collection: 'contact',
data: { name: 'Segundo Contato', phone, state: 'BA', city: 'Salvador' },
data: { name: 'Segundo Contato', phones: [{ value: phone }], state: 'BA', city: 'Salvador' },
})
// Two fichas already share the phone, so the leadership create cannot
@ -304,7 +307,7 @@ describe('campaign leadership domain', () => {
createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Contato Reutilizado',
phone,
phones: [phone],
supportStatus: 'engajado',
}),
).resolves.toMatchObject({ contactReused: false })
@ -345,14 +348,14 @@ describe('campaign leadership domain', () => {
const ownLeadership = await createLeadershipRecord(payload, advisor, {
municipalities: [assigned.id],
name: 'Liderança do município',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
notes: 'Avaliação interna',
})
await createLeadershipRecord(payload, coordinator, {
municipalities: [other.id],
name: 'Liderança Alheia',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
@ -376,7 +379,7 @@ describe('campaign leadership domain', () => {
createLeadershipRecord(payload, advisor, {
municipalities: [other.id],
name: 'Cadastro Fora do Escopo',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
}),
).rejects.toThrow(OUT_OF_SCOPE_MUNICIPALITY_MESSAGE)
@ -392,7 +395,7 @@ describe('campaign leadership domain', () => {
const own = await createLeadershipRecord(payload, advisor, {
municipalities: [assigned.id],
name: 'Ação Permitida',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
@ -413,7 +416,7 @@ describe('campaign leadership domain', () => {
const otherLeadership = await createLeadershipRecord(payload, coordinator, {
municipalities: [other.id],
name: 'Outra Liderança',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
await expect(
@ -432,7 +435,7 @@ describe('campaign leadership domain', () => {
createLeadershipRecord(payload, leader, {
municipalities: [municipality.id],
name: 'Criação por liderança',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
}),
).rejects.toThrow('Somente a coordenação e a assessoria podem gerenciar lideranças.')
@ -473,7 +476,7 @@ describe('campaign leadership domain', () => {
createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Contato que Deve Reverter',
phone,
phones: [phone],
supportStatus: 'engajado',
}),
).rejects.toThrow('falha forçada após contato')
@ -482,7 +485,7 @@ describe('campaign leadership domain', () => {
const [contacts, leaderships] = await Promise.all([
payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
pagination: false,
}),
@ -602,7 +605,7 @@ describe('campaign leadership domain', () => {
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Registro Permanente',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'a_abordar',
})
@ -663,7 +666,7 @@ describe('campaign leadership domain', () => {
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Nome Original',
phone,
phones: [phone],
email: 'original@example.com',
supportStatus: 'engajado',
})
@ -693,7 +696,7 @@ describe('campaign leadership domain', () => {
})
expect(contact.name).toBe('Nome Corrigido')
expect(contact.email).toBe('corrigido@example.com')
expect(contact.phone).toBe(newPhone)
expect(contact.phones?.[0]?.value).toBe(newPhone)
})
it('shares a leadership contact phone with another person (C111)', async () => {
@ -701,11 +704,11 @@ describe('campaign leadership domain', () => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const takenPhone = fixtures.phone()
await fixtures.createContact({ phone: takenPhone })
await fixtures.createContact({ phones: [{ value: takenPhone }] })
const leadership = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Sem Conflito',
phone: fixtures.phone(),
phones: [fixtures.phone()],
supportStatus: 'engajado',
})
@ -721,11 +724,11 @@ describe('campaign leadership domain', () => {
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(takenPhone)
expect(contact.phones?.[0]?.value).toBe(takenPhone)
const withPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: takenPhone } },
where: { 'phones.value': { equals: takenPhone } },
depth: 0,
limit: 2,
pagination: false,
@ -744,7 +747,7 @@ describe('campaign leadership domain', () => {
const leadership = await createLeadershipRecord(payload, coordinator, {
municipalities: [away.id],
name: 'Fora da Carteira',
phone: fixtures.phone(),
phones: [fixtures.phone()],
supportStatus: 'engajado',
})

View file

@ -37,7 +37,7 @@ const createFormData = (municipalityIds: number[], name: string, phone: string)
for (const municipalityId of municipalityIds)
formData.append('municipalities', String(municipalityId))
formData.set('name', name)
formData.set('phone', phone)
formData.append('phones', phone)
formData.set('supportStatus', 'engajado')
return formData
}
@ -82,7 +82,7 @@ describe('campaign leadership exported form actions', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
limit: 2,
depth: 0,
})
@ -114,14 +114,14 @@ describe('campaign leadership exported form actions', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
limit: 2,
depth: 0,
})
expect(contacts.totalDocs).toBe(1)
expect(contacts.docs[0]).toMatchObject({
name: 'Francisco',
phone,
phones: [{ value: phone }],
})
const leaderships = await payload.find({
collection: 'leadership',
@ -145,7 +145,7 @@ describe('campaign leadership exported form actions', () => {
const formData = new FormData()
formData.append('municipalities', String(municipality.id))
formData.set('name', 'Liderança mínima')
formData.set('phone', phone)
formData.append('phones', phone)
authState.user = coordinator
await expectRedirectTo(createLeadershipFormAction({}, formData), '/campanha/liderancas/')
@ -153,14 +153,14 @@ describe('campaign leadership exported form actions', () => {
const contact = (
await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
limit: 1,
depth: 0,
})
).docs[0]!
expect(contact).toMatchObject({
name: 'Liderança mínima',
phone,
phones: [{ value: phone }],
email: null,
gender: null,
})
@ -188,7 +188,7 @@ describe('campaign leadership exported form actions', () => {
await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Pessoa já cadastrada',
phone,
phones: [phone],
supportStatus: 'engajado',
})
@ -207,7 +207,7 @@ describe('campaign leadership exported form actions', () => {
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança com campos internos',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
exclusive: true,
notes: 'Nota para limpar',
})
@ -234,14 +234,14 @@ describe('campaign leadership exported form actions', () => {
const defaulted = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança exclusiva default',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
})
expect(defaulted.exclusive).toBe(true)
const divided = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança dividida',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
exclusive: false,
})
expect(divided.exclusive).toBe(false)
@ -258,7 +258,7 @@ describe('campaign leadership exported form actions', () => {
const otherLeadership = await createLeadershipRecord(payload, coordinator, {
municipalities: [other.id],
name: 'Liderança fora do escopo',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
authState.user = advisor
@ -288,7 +288,7 @@ describe('campaign leadership exported form actions', () => {
const existing = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Registro protegido',
phone: campaignFixtures().phone(),
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
authState.user = leaderAccount

View file

@ -0,0 +1,458 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { CampaignUser } from '@/payload-types'
const authState = vi.hoisted(() => ({
user: null as CampaignUser | null,
}))
const requestHeadersState = vi.hoisted(() => ({
value: new Headers({ origin: 'http://localhost:3212' }),
}))
vi.mock('@/utilities/campaignAuth', () => ({
getCampaignUser: async () => authState.user,
}))
vi.mock('next/headers', () => ({
headers: async () => requestHeadersState.value,
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
import {
createLeadershipRecord,
updateLeadershipContactRecord,
} from '@/app/(campaign)/campanha/actions/leadership'
import {
createSupporterRecord,
updateSupporterContactRecord,
} from '@/app/(campaign)/campanha/actions/supporter'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import {
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
primaryPhoneOf,
reorderWithPrimaryPhone,
} from '@/lib/phone'
import config from '@/payload.config'
import { toSupporterDetailViewModel } from '@/utilities/supporter/supporterViewModels'
import {
ensureLeasedConsent,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
} from '../helpers/testDatabaseLease'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('C112 — múltiplos telefones por pessoa', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('keeps order = priority through the pure helpers', () => {
expect(primaryPhoneOf(null)).toBeNull()
expect(primaryPhoneOf([])).toBeNull()
expect(primaryPhoneOf([{ value: '71911110000' }, { value: '71922220000' }])).toBe('71911110000')
expect(reorderWithPrimaryPhone([], '71911110000')).toEqual(['71911110000'])
expect(
reorderWithPrimaryPhone([{ value: '71911110000' }, { value: '71922220000' }], '71922220000'),
).toEqual(['71922220000', '71911110000'])
expect(reorderWithPrimaryPhone([{ value: '71911110000' }], null)).toEqual([])
})
it('creates a ficha with N phones on leadership create and lists the primary', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const primary = campaignFixtures().phone()
const secondary = campaignFixtures().phone()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Multi Telefones',
phones: [primary, secondary],
supportStatus: 'engajado',
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(created.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([primary, secondary])
const summary = await payload.find({
collection: 'leadership',
where: { id: { equals: created.id } },
depth: 1,
limit: 1,
pagination: false,
user: coordinator,
overrideAccess: false,
})
const contactSummary = summary.docs[0]?.contact
expect(
typeof contactSummary === 'object' && contactSummary !== null
? primaryPhoneOf(contactSummary.phones)
: null,
).toBe(primary)
})
it('rejects the same number twice in one ficha (dedupe within the person)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const phone = campaignFixtures().phone()
await expect(
createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Repetida',
phones: [phone, phone],
supportStatus: 'engajado',
}),
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
await expect(
payload.create({
collection: 'contact',
data: {
name: 'Ficha Repetida',
phones: [{ value: phone }, { value: phone }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
overrideAccess: true,
}),
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
// The invalid-number refusal is unchanged.
await expect(
payload.create({
collection: 'contact',
data: {
name: 'Ficha Inválida',
phones: [{ value: '1199999999' }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
overrideAccess: true,
}),
).rejects.toThrow(BRAZILIAN_PHONE_INVALID_MESSAGE)
})
it('sets the primary phone preserving the rest through the inline edit', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const first = campaignFixtures().phone()
const second = campaignFixtures().phone()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Troca Principal',
phones: [first, second],
supportStatus: 'engajado',
})
const updated = await updateLeadershipContactRecord(payload, coordinator, {
id: created.id,
field: 'phone',
phone: second,
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([second, first])
// Clearing the primary removes it — the rest shifts up.
await updateLeadershipContactRecord(payload, coordinator, {
id: created.id,
field: 'phone',
phone: null,
})
const afterClear = await payload.findByID({
collection: 'contact',
id: relationId(created.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(afterClear.phones?.map((entry) => entry.value)).toEqual([first])
})
it('writes the full list through the phones branch (ficha editor)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Liderança Editor',
phones: [campaignFixtures().phone()],
supportStatus: 'engajado',
})
const kept = campaignFixtures().phone()
const added = campaignFixtures().phone()
const updated = await updateLeadershipContactRecord(payload, coordinator, {
id: created.id,
field: 'phones',
phones: [kept, added],
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([kept, added])
})
it('supporter detail shows every number and the list still shows the primary', async () => {
const fixtures = campaignFixtures()
await ensureLeasedConsent(payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const primary = fixtures.phone()
const secondary = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador Multi Telefones'),
phones: [primary, secondary],
municipality: municipality.id,
city: 'Salvador',
consentAccepted: true,
})
const supporter = await payload.findByID({
collection: 'supporter',
id: created.id,
depth: 1,
overrideAccess: true,
})
const detail = toSupporterDetailViewModel(supporter)
expect(detail.phones).toEqual([primary, secondary])
expect(detail.phone).toBe(primary)
// The supporter ficha editor (phones branch) replaces the whole list.
const third = fixtures.phone()
const updated = await updateSupporterContactRecord(payload, coordinator, {
id: created.id,
field: 'phones',
phones: [third, primary],
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([third, primary])
})
})
describe('C112 — busca e convite com números secundários', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('finds the supporter by a NON-primary number (payload and SQL search paths)', async () => {
const fixtures = campaignFixtures()
await ensureLeasedConsent(payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const primary = fixtures.phone()
const secondary = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador Busca Secundária'),
phones: [primary, secondary],
municipality: municipality.id,
city: 'Salvador',
consentAccepted: true,
})
// Payload where path: the list search terms normalize to the full number.
const { toPayloadWhere } = await import('@/utilities/supporter/supporterListFilters')
const where = toPayloadWhere({ q: secondary })
const hits = await payload.find({
collection: 'supporter',
where,
depth: 1,
limit: 10,
pagination: false,
overrideAccess: true,
})
expect(hits.docs.map((doc) => doc.id)).toContain(created.id)
// SQL aggregate path: the raw-conditions builder must match a secondary too.
const { toAggregateSqlConditions } =
await import('@/utilities/supporter/supporterListSqlFilters')
const sql = await import('@payloadcms/db-postgres')
const { conditions, needsContactJoin } = toAggregateSqlConditions({
q: secondary,
})
expect(needsContactJoin).toBe(true)
expect(conditions.length).toBeGreaterThan(0)
const conditionSql = sql.sql.join(conditions, sql.sql` AND `)
const result = await payload.db.drizzle.execute(
sql.sql`SELECT "supporter"."id" FROM "supporter"
JOIN "contact" ON "contact"."id" = "supporter"."contact_id"
WHERE ${conditionSql}`,
)
const rows = Array.isArray(result)
? result
: ((result as { rows: unknown }).rows as Array<{ id: number }>)
const ids = rows.map((row) => row.id)
expect(ids).toContain(created.id)
})
it('builds the invite WhatsApp link with the PRIMARY of a multi-phone ficha', async () => {
const { buildCampaignInviteWhatsAppLink } = await import('@/utilities/campaignInvite')
const primary = campaignFixtures().phone()
const secondary = campaignFixtures().phone()
const url = buildCampaignInviteWhatsAppLink({
phone: primary,
recipientName: 'Liderança',
senderName: 'Coordenação',
inviteUrl: 'https://pt.jorgesolla.com.br/campanha/convite/abc',
kind: 'autopreenchimento',
})
expect(url).toContain(`wa.me/55${primary}`)
expect(url).not.toContain(`wa.me/55${secondary}`)
// And the invitation creation gate uses the ficha's primary.
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: campaignFixtures().value('Liderança Convite Multi'),
phones: [primary, secondary],
supportStatus: 'engajado',
})
const { createCampaignInvite } = await import('@/app/(campaign)/campanha/actions/invite')
authState.user = coordinator
const invite = await createCampaignInvite({
leadership: created.id,
kind: 'autopreenchimento',
})
expect(invite.whatsappUrl).toContain(`wa.me/55${primary}`)
})
})
describe('C112 — dobradinha e assessor com N telefones', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('writes the dobradinha ficha list through the phones branch', async () => {
const { updateStateDeputyContactRecord } =
await import('@/app/(campaign)/campanha/actions/stateDeputy')
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({
name: `${fixtures.value('Dobradinha')} Multi`,
})
const first = fixtures.phone()
const second = fixtures.phone()
const updated = await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'phones',
phones: [first, second],
})
const contact = await payload.findByID({
collection: 'contact',
id: relationId(updated.contact) as number,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second])
})
it('writes the advisor ficha list and rejects duplicates', async () => {
const { updateAdvisorContactFicha } = await import('@/app/(campaign)/campanha/actions/advisor')
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
authState.user = coordinator
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora Multi Telefones'),
})
const first = fixtures.phone()
const second = fixtures.phone()
const contactID = relationId(
await payload
.findByID({
collection: 'campaignUser',
id: advisor.id,
depth: 0,
select: { contact: true },
overrideAccess: true,
})
.then((account) => account.contact),
)
expect(contactID).not.toBeNull()
await updateAdvisorContactFicha({ contactId: contactID!, phones: [first, second] })
const contact = await payload.findByID({
collection: 'contact',
id: contactID!,
depth: 0,
overrideAccess: true,
})
expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second])
await expect(
updateAdvisorContactFicha({ contactId: contactID!, phones: [first, first] }),
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
})
})
describe('C112 — CSV import com telefone compartilhado como secundário', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('flags a CSV phone carried as SECONDARY by two fichas (preview = confirm fail-closed)', async () => {
const fixtures = campaignFixtures()
await ensureLeasedConsent(payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const sharedSecondary = fixtures.phone()
// Two fichas whose PRIMARY differs but which both carry the CSV number.
await fixtures.createContact({
phones: [{ value: fixtures.phone() }, { value: sharedSecondary }],
})
await fixtures.createContact({
phones: [{ value: fixtures.phone() }, { value: sharedSecondary }],
})
const { previewSupporterImportText } =
await import('@/app/(campaign)/campanha/actions/supporterImport')
const csv = `nome,telefone,municipio,intencao\nMaria Souza,${sharedSecondary},Salvador,certo`
const preview = await previewSupporterImportText(payload, coordinator, csv)
expect(preview.counts.ok).toBe(0)
expect(preview.sampleRows.some((row) => row.status === 'telefone_compartilhado')).toBe(true)
})
})

View file

@ -152,7 +152,7 @@ describe('B155 — lideranças na lista de municípios (leitura reversa + opçõ
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBeNull()
expect(contact.phones).toEqual([])
const { leadershipIDsByMunicipality, summariesById } =
await loadMunicipalityLeadershipSummaries(payload, coordinator, [municipality.id])
@ -170,13 +170,13 @@ describe('B155 — lideranças na lista de municípios (leitura reversa + opçõ
const first = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Nome Original',
phone,
phones: [phone],
})
await expect(
createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Outro Nome',
phone,
phones: [phone],
}),
).rejects.toThrow('Esta pessoa já está cadastrada como liderança')

View file

@ -229,7 +229,7 @@ describe('StateDeputy contact fields (B163)', () => {
expect(updated.contact).toMatchObject({
name: correctedName,
email: 'corrigido@example.com',
phone,
phones: expect.arrayContaining([expect.objectContaining({ value: phone })]),
})
})
@ -240,7 +240,7 @@ describe('StateDeputy contact fields (B163)', () => {
name: `${fixtures.value('Dobradinha')} Principal`,
})
const takenPhone = fixtures.phone()
await fixtures.createContact({ phone: takenPhone })
await fixtures.createContact({ phones: [{ value: takenPhone }] })
const updated = await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
@ -254,11 +254,11 @@ describe('StateDeputy contact fields (B163)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(takenPhone)
expect(contact.phones?.[0]?.value).toBe(takenPhone)
const withPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: takenPhone } },
where: { 'phones.value': { equals: takenPhone } },
depth: 0,
limit: 2,
pagination: false,

View file

@ -74,7 +74,7 @@ describe('campaign supporter domain', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71999990000',
phones: ['71999990000'],
consentAccepted: true,
}).success,
).toBe(true)
@ -82,14 +82,14 @@ describe('campaign supporter domain', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71999990000',
phones: ['71999990000'],
}).success,
).toBe(false)
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: 'invalid',
phones: ['invalid'],
consentAccepted: true,
}).success,
).toBe(false)
@ -105,7 +105,7 @@ describe('campaign supporter domain', () => {
await expect(
createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
consentAccepted: true,
}),
).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/)
@ -126,7 +126,7 @@ describe('campaign supporter domain', () => {
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador'),
phone,
phones: [phone],
city: 'Salvador',
consentAccepted: true,
})
@ -146,7 +146,7 @@ describe('campaign supporter domain', () => {
await expect(
createSupporterRecord(payload, coordinator, {
name: contact.name,
phone: contact.phone,
phones: [contact.phone],
municipality: municipality.id,
consentAccepted: true,
}),
@ -164,7 +164,7 @@ describe('campaign supporter domain', () => {
await expect(
createSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
consentAccepted: true,
}),
).rejects.toThrow(/sem município/)
@ -172,7 +172,7 @@ describe('campaign supporter domain', () => {
await expect(
createSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
municipality: otherMunicipality.id,
consentAccepted: true,
}),
@ -180,7 +180,7 @@ describe('campaign supporter domain', () => {
const created = await createSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
municipality: municipality.id,
consentAccepted: true,
})
@ -247,7 +247,7 @@ describe('campaign supporter domain', () => {
const advisor = await fixtures.createCampaignUser('advisor')
const phoneOk = fixtures.phone()
const phoneDup = fixtures.phone()
const existing = await fixtures.createContact({ phone: phoneDup })
const existing = await fixtures.createContact({ phones: [{ value: phoneDup }] })
await fixtures.createSupporter({
contact: existing.id,
createdBy: coordinator.id,
@ -288,7 +288,7 @@ describe('campaign supporter domain', () => {
const contact = await payload.find({
collection: 'contact',
where: { phone: { equals: phoneOk } },
where: { 'phones.value': { equals: phoneOk } },
depth: 0,
limit: 1,
overrideAccess: true,
@ -312,8 +312,8 @@ describe('campaign supporter domain', () => {
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const sharedPhone = fixtures.phone()
await fixtures.createContact({ name: 'Comitê Central', phone: sharedPhone })
await fixtures.createContact({ name: 'Ana', phone: sharedPhone })
await fixtures.createContact({ name: 'Comitê Central', phones: [{ value: sharedPhone }] })
await fixtures.createContact({ name: 'Ana', phones: [{ value: sharedPhone }] })
const csv = [
'nome,telefone,municipio,intencao',
@ -341,7 +341,7 @@ describe('campaign supporter domain', () => {
await ensureSupporterConsents()
const coordinator = await fixtures.createCampaignUser('coordinator')
const phone = fixtures.phone()
await fixtures.createContact({ phone })
await fixtures.createContact({ phones: [{ value: phone }] })
const csv = `nome,telefone,municipio,intencao\nAna Silva,${phone},Salvador,certo`
const preview = await previewSupporterImportText(payload, coordinator, csv)
@ -349,7 +349,7 @@ describe('campaign supporter domain', () => {
// Simulate the race: between preview and confirm another ficha claims the
// same phone. The confirm holds the phone locks and must fail closed.
await fixtures.createContact({ name: 'Segunda Ficha', phone })
await fixtures.createContact({ name: 'Segunda Ficha', phones: [{ value: phone }] })
await expect(
confirmSupporterImportRecord(payload, coordinator, {
@ -394,7 +394,7 @@ describe('campaign supporter domain', () => {
})
const contact = await payload.find({
collection: 'contact',
where: { phone: { equals: phoneOk } },
where: { 'phones.value': { equals: phoneOk } },
depth: 0,
limit: 1,
overrideAccess: true,
@ -417,7 +417,7 @@ describe('campaign supporter domain', () => {
const phone = fixtures.phone()
const created = await createSupporterRecord(payload, coordinator, {
name: fixtures.value('Apoiador'),
phone,
phones: [phone],
consentAccepted: true,
})
const contactID = relationId(created.contact)
@ -434,7 +434,7 @@ describe('campaign supporter domain', () => {
depth: 0,
})
expect(contact.name).toBe('Titular removido')
expect(contact.phone).not.toBe(phone)
expect(contact.phones?.map((entry) => entry.value)).not.toContain(phone)
})
it('aggregates supporter overview KPIs in a single SQL query for the coordinator', async () => {

View file

@ -29,7 +29,6 @@ const createEngagedPhoneAccessGraph = async (
) => {
const contact = await fixtures.createContact({
name: fixtures.value('Contato telefone'),
phone: fixtures.phone(),
})
return fixtures.createLeadership({
contact,

View file

@ -337,7 +337,7 @@ describe('leader supporter creation scope', () => {
const supporter = await withSupporterRegistrationConsent(() =>
createLeaderSupporterRecord(payload, leaderAccount, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
city: 'Salvador',
municipality: linked.id,
consentAccepted: true,
@ -367,7 +367,7 @@ describe('leader supporter creation scope', () => {
withSupporterRegistrationConsent(() =>
createLeaderSupporterRecord(payload, leaderAccount, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
city: 'Salvador',
municipality: outside.id,
consentAccepted: true,
@ -384,7 +384,7 @@ describe('leader supporter creation scope', () => {
await expect(
createLeaderSupporterRecord(payload, advisor, {
name: fixtures.value('Apoiador'),
phone: fixtures.phone(),
phones: [fixtures.phone()],
city: 'Salvador',
municipality: municipality.id,
consentAccepted: true,

View file

@ -25,14 +25,14 @@ const contactPhoneHook = Contact.hooks?.beforeValidate?.[0]
type ContactPhoneHookArgs = Parameters<NonNullable<typeof contactPhoneHook>>[0]
const runContactPhoneHook = async (phone: string) => {
const runContactPhoneHook = async (phones: unknown) => {
if (typeof contactPhoneHook !== 'function') {
throw new Error('Expected Contact.beforeValidate phone hook.')
}
const data = { phone }
const data: { phones?: unknown } = { phones }
await contactPhoneHook(stub<ContactPhoneHookArgs>({ data }))
return data.phone
return data.phones
}
describe('Contact phone normalization boundary', () => {
@ -44,21 +44,35 @@ describe('Contact phone normalization boundary', () => {
const parsed = leadershipCreateSchema.parse({
municipalities: [1],
name: 'Liderança normalizada',
phone: '+55 (71) 99999-1234',
phones: ['+55 (71) 99999-1234'],
})
expect(parsed.phone).toBe('71999991234')
expect(await runContactPhoneHook(parsed.phone)).toBe('71999991234')
expect(parsed.phones).toEqual(['71999991234'])
expect(await runContactPhoneHook([{ value: parsed.phones[0] }])).toEqual([
{ value: '71999991234' },
])
expect(phoneMocks.normalizeBrazilianPhone).toHaveBeenCalledTimes(1)
})
it('normalizes a formatted phone sent directly to the Contact API boundary', async () => {
expect(await runContactPhoneHook('+55 (71) 99999-1234')).toBe('71999991234')
expect(await runContactPhoneHook([{ value: '+55 (71) 99999-1234' }])).toEqual([
{ value: '71999991234' },
])
expect(phoneMocks.normalizeBrazilianPhone).toHaveBeenCalledTimes(1)
})
it('rejects an invalid canonical-length direct API phone without re-normalizing it', async () => {
await expect(runContactPhoneHook('00000000000')).rejects.toThrow('Celular brasileiro inválido.')
await expect(runContactPhoneHook([{ value: '00000000000' }])).rejects.toThrow(
'Celular brasileiro inválido.',
)
expect(phoneMocks.normalizeBrazilianPhone).not.toHaveBeenCalled()
})
it('drops empty entries and rejects a duplicate within the same ficha', async () => {
expect(await runContactPhoneHook([{ value: '' }, { value: null }])).toEqual([])
await expect(
runContactPhoneHook([{ value: '71999991234' }, { value: '71999991234' }]),
).rejects.toThrow('Telefone repetido na ficha.')
})
})

View file

@ -130,7 +130,8 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => {
overrideAccess: true,
})
expect(remaining.name).toBe('Titular removido')
expect(remaining.phone).toMatch(/^999\d{8}$/)
expect(remaining.phones?.[0]?.value).toMatch(/^999\d{8}$/)
expect(remaining.phones).toHaveLength(1)
expect(remaining.email).toBeNull()
expect(remaining.city).toBeNull()
} finally {

View file

@ -95,7 +95,7 @@ describe('submitPetitionSignature (server-side consent resolution)', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
pagination: false,

View file

@ -70,7 +70,7 @@ describe('submitWhatsapp (consent by stable key)', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
pagination: false,

View file

@ -46,7 +46,7 @@ describe('supporterListFilters', () => {
or: [
{ 'contact.name': { contains: '(71) 98888-7777' } },
{ 'contact.city': { contains: '(71) 98888-7777' } },
{ 'contact.phone': { equals: '71988887777' } },
{ 'contact.phones.value': { equals: '71988887777' } },
],
},
],

View file

@ -26,7 +26,7 @@ describe('signatureExport', () => {
id: 1,
name: 'Maria Silva',
email: 'maria@example.com',
phone: '71999990000',
phones: [{ value: '71999990000' }],
state: 'BA',
city: 'Salvador',
}),
@ -44,7 +44,7 @@ describe('signatureExport', () => {
id: 2,
name: 'João Souza',
email: null,
phone: '71988887777',
phones: [{ value: '71988887777' }],
state: 'BA',
city: 'Feira de Santana',
})

View file

@ -10,15 +10,15 @@ import {
} from '@/lib/schemas/supporter'
describe('supporter schemas', () => {
it('normalizes phone and optional BA municipality', () => {
it('normalizes phones and optional BA municipality', () => {
const parsed = supporterCreateSchema.parse({
name: 'Maria da Silva',
phone: '+55 (71) 98888-7777',
phones: ['+55 (71) 98888-7777'],
city: 'salvador',
consentAccepted: true,
})
expect(parsed.phone).toBe('71988887777')
expect(parsed.phones).toEqual(['71988887777'])
expect(parsed.city).toBe('Salvador')
expect(resolveBahiaMunicipality('feira de santana')).toBe('Feira de Santana')
expect(resolveBahiaMunicipality('Nowhere')).toBeNull()
@ -28,7 +28,7 @@ describe('supporter schemas', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71988887777',
phones: ['71988887777'],
voteIntention: 'certo',
consentAccepted: true,
}).success,
@ -37,7 +37,7 @@ describe('supporter schemas', () => {
expect(
supporterCreateSchema.safeParse({
name: 'Maria Silva',
phone: '71988887777',
phones: ['71988887777'],
voteIntention: 'certo',
consentAccepted: true,
voteIntentionConsentAccepted: true,