From 6de8fad8f67b51f0f560d023344ef9f1f194038c Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 10:47:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(C112):=20m=C3=BAltiplos=20telefones=20por?= =?UTF-8?q?=20pessoa=20=E2=80=94=20Contact.phones=20array=20(ordem=20=3D?= =?UTF-8?q?=20prioridade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contact.phone (text único) → phones (array field Payload, subcampo value): fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios, rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado) - migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid) antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json gerado mantém as colunas C99/C100 já existentes) - primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts (ordem = prioridade; inline edit = seta o principal preservando o resto) - findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz append dos faltantes; locks em todos os números digitados) - sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones - convite redemption: telefone digitado vira principal, demais preservados - import CSV: preview sinaliza compartilhado também por número secundário; bulk insere contact_phones (JOIN nas leituras, uuid no insert) - busca por telefone (apoiadores, pickers, combobox) casa qualquer número - UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓, modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista), apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard - UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone; as ações mapeiam phone → phones[0] - testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99 atualizados, e2e wizard/apoiador ajustados --- .../multiplos-telefones-por-pessoa-impl.md | 71 + scripts/seed-minimal.mjs | 16 +- .../acoes/wizardLeadershipFormActions.ts | 9 +- .../(app)/apoiadores/[id]/formActions.ts | 33 +- .../campanha/(app)/apoiadores/[id]/page.tsx | 16 +- .../(app)/apoiadores/novo/formActions.ts | 15 +- .../campanha/(app)/assessores/[id]/page.tsx | 30 +- .../campanha/(app)/assessores/formActions.ts | 22 + .../(app)/atividades/contactSearchActions.ts | 11 +- .../campanha/(app)/dobradinhas/[id]/page.tsx | 2 +- .../campanha/(app)/dobradinhas/formActions.ts | 7 + .../campanha/(app)/liderancas/[id]/page.tsx | 2 +- .../campanha/(app)/liderancas/formActions.ts | 7 + .../(app)/liderancas/nova/formActions.ts | 9 +- .../(campaign)/campanha/actions/advisor.ts | 24 + .../campanha/actions/leaderSupporter.ts | 17 +- .../(campaign)/campanha/actions/leadership.ts | 32 +- .../campanha/actions/stateDeputy.ts | 23 +- .../(campaign)/campanha/actions/supporter.ts | 60 +- .../campanha/actions/supporterImport.ts | 23 +- .../actions/submitPetitionSignature.ts | 8 +- src/app/(frontend)/actions/submitWhatsapp.ts | 8 +- src/collections/CampaignUser.ts | 30 +- src/collections/Contact.ts | 94 +- .../campaign/leadership/LeaderContactForm.tsx | 34 +- .../leadership/LeadershipContactSection.tsx | 20 +- .../campaign/leadership/LeadershipForm.tsx | 22 +- .../leadership/WizardLeadershipForm.tsx | 24 +- .../campaign/shared/PhonesFieldEditor.tsx | 285 + .../stateDeputy/StateDeputyContactSection.tsx | 21 +- .../campaign/supporter/SupporterForm.tsx | 30 +- src/lib/formData.ts | 18 + src/lib/phone.ts | 35 + src/lib/schemas/contact.ts | 20 + src/lib/schemas/leadership.ts | 7 +- src/lib/schemas/supporter.ts | 6 +- src/lib/wizardLeadershipContract.ts | 2 + ...60811_111828_add_contact_phones_array.json | 13337 ++++++++++++++++ ...0260811_111828_add_contact_phones_array.ts | 41 + src/migrations/index.ts | 6 + src/payload-types.ts | 14 +- src/utilities/activityLeadershipOptions.ts | 2 +- src/utilities/activityResponsibleSearch.ts | 2 +- src/utilities/activityViewModels.ts | 3 +- src/utilities/advisorData.ts | 28 +- src/utilities/campaignInviteCreation.ts | 6 +- src/utilities/campaignInvitePageData.ts | 5 +- src/utilities/campaignInviteRedemption.ts | 22 +- src/utilities/contactIdentity.ts | 58 +- .../homeSearch/searchHomeLeaderships.ts | 7 +- src/utilities/leaderContactsPageData.ts | 3 +- src/utilities/leadership/leadershipData.ts | 22 +- src/utilities/people/peopleData.ts | 3 +- src/utilities/people/personDelete.ts | 7 +- src/utilities/signatureExport.ts | 3 +- src/utilities/stateDeputyData.ts | 11 +- .../supporter/supporterImportBulk.ts | 40 +- .../supporter/supporterListFilters.ts | 5 +- .../supporter/supporterListSqlFilters.ts | 9 +- .../supporter/supporterViewModels.ts | 10 +- tests/e2e/campaignHomeActions.e2e.spec.ts | 2 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 4 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 +- tests/helpers/campaignFixtures.ts | 20 +- tests/int/campaignContactIdentity.int.spec.ts | 40 +- tests/int/campaignFixtures.int.spec.ts | 6 +- tests/int/campaignInvite.int.spec.ts | 25 +- tests/int/campaignInviteUi.int.spec.ts | 10 +- tests/int/campaignLeadership.int.spec.ts | 65 +- .../int/campaignLeadershipActions.int.spec.ts | 26 +- tests/int/campaignMultiPhones.int.spec.ts | 458 + ...aignMunicipalityLeadershipList.int.spec.ts | 6 +- ...nMunicipalityStateDeputyCreate.int.spec.ts | 8 +- tests/int/campaignSupporter.int.spec.ts | 36 +- tests/int/campaignUserPhone.int.spec.ts | 1 - .../int/collectionAccessLockdown.int.spec.ts | 6 +- .../int/contactPhoneNormalization.int.spec.ts | 30 +- tests/int/personDelete.int.spec.ts | 3 +- tests/int/submitPetitionSignature.int.spec.ts | 2 +- tests/int/submitWhatsapp.int.spec.ts | 2 +- tests/int/supporterListFilters.int.spec.ts | 2 +- tests/unit/signatureExport.unit.spec.ts | 4 +- tests/unit/supporterSchema.unit.spec.ts | 10 +- 83 files changed, 15116 insertions(+), 389 deletions(-) create mode 100644 docs/plans/multiplos-telefones-por-pessoa-impl.md create mode 100644 src/components/campaign/shared/PhonesFieldEditor.tsx create mode 100644 src/migrations/20260811_111828_add_contact_phones_array.json create mode 100644 src/migrations/20260811_111828_add_contact_phones_array.ts create mode 100644 tests/int/campaignMultiPhones.int.spec.ts diff --git a/docs/plans/multiplos-telefones-por-pessoa-impl.md b/docs/plans/multiplos-telefones-por-pessoa-impl.md new file mode 100644 index 00000000..b7e70624 --- /dev/null +++ b/docs/plans/multiplos-telefones-por-pessoa-impl.md @@ -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 diff --git a/scripts/seed-minimal.mjs b/scripts/seed-minimal.mjs index 60d97938..73c6cb5d 100644 --- a/scripts/seed-minimal.mjs +++ b/scripts/seed-minimal.mjs @@ -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) diff --git a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts index dea6c4d2..fc94467d 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts @@ -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), diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index 6ff4c709..eb0635e9 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -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): { 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 7b6f2bac..5ada0bee 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -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 Dados de contato -

{supporter.phoneDisplay || '—'}

+ {supporter.email ?

{supporter.email}

: null}

{supporter.municipalityName ? ( diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 777b905d..1e62292b 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -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 } : {}), diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index a11b028f..d698d1da 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -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 )} +

+

+ Telefones da pessoa +

+

+ Todos os números da ficha — o primeiro é o principal (listas, WhatsApp e convites). +

+ {advisor.contactID !== null ? ( + + ) : ( +

Ficha de contato ainda não vinculada.

+ )} +
+

Acesso diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts index bc59f427..83afa3d8 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -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 => + 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, diff --git a/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts index 44d7e7aa..c708e250 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts @@ -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 ( diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx index ed5f5538..b2087d34 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx @@ -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 ? (

{stateDeputy.notes}

diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index 88e25e7a..ac66627d 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -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) } diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index 7da9f977..c63de589 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -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 ? ( diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index f1833ac1..8d9d409b 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -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) } diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts index bad50f50..3374efc7 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -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'), diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts index 4149f3fe..790aa2aa 100644 --- a/src/app/(campaign)/campanha/actions/advisor.ts +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -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, diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index d2da6385..a6d6f2a6 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -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 => { 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, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index be8b4f16..ace7df07 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -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 -type ValidatedLeadershipCreateData = Omit & { - phone?: string | null +type ValidatedLeadershipCreateData = Omit & { + 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> = {} + const contactData: Partial> & { + 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, diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index 9aca7413..7db83759 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -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> = {} + const contactData: Partial> & { + 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. diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 0e2f62db..f502500d 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -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 => 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, diff --git a/src/app/(campaign)/campanha/actions/supporterImport.ts b/src/app/(campaign)/campanha/actions/supporterImport.ts index a8e5ca44..ed6d5fc2 100644 --- a/src/app/(campaign)/campanha/actions/supporterImport.ts +++ b/src/app/(campaign)/campanha/actions/supporterImport.ts @@ -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() const sharedPhones = new Set() 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()] diff --git a/src/app/(frontend)/actions/submitPetitionSignature.ts b/src/app/(frontend)/actions/submitPetitionSignature.ts index f4205879..03a8f7d5 100644 --- a/src/app/(frontend)/actions/submitPetitionSignature.ts +++ b/src/app/(frontend)/actions/submitPetitionSignature.ts @@ -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 }, }) diff --git a/src/app/(frontend)/actions/submitWhatsapp.ts b/src/app/(frontend)/actions/submitWhatsapp.ts index 807d457e..54126aed 100644 --- a/src/app/(frontend)/actions/submitWhatsapp.ts +++ b/src/app/(frontend)/actions/submitWhatsapp.ts @@ -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 }, }) diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index d64a2844..192e541b 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -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 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 diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 180de13d..743e3e7f 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -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() + const normalized: ContactPhoneValue[] = [] + + for (const entry of phones) { + if (entry === null || entry === undefined) continue + const raw = + typeof entry === 'object' && 'value' in (entry as Record) + ? (entry as Record).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', diff --git a/src/components/campaign/leadership/LeaderContactForm.tsx b/src/components/campaign/leadership/LeaderContactForm.tsx index 534f5f4a..299b3bf9 100644 --- a/src/components/campaign/leadership/LeaderContactForm.tsx +++ b/src/components/campaign/leadership/LeaderContactForm.tsx @@ -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} - - Celular * - - {phoneField.error ? ( - {phoneField.error} - ) : null} - + Nome * @@ -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} /> diff --git a/src/components/campaign/leadership/LeadershipContactSection.tsx b/src/components/campaign/leadership/LeadershipContactSection.tsx index 232ac990..76ff1ed6 100644 --- a/src/components/campaign/leadership/LeadershipContactSection.tsx +++ b/src/components/campaign/leadership/LeadershipContactSection.tsx @@ -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) => (

@@ -50,16 +52,16 @@ export const LeadershipContactSection = ({
-
Celular
+
+ Celulares (o primeiro é o principal) +
-
diff --git a/src/components/campaign/leadership/LeadershipForm.tsx b/src/components/campaign/leadership/LeadershipForm.tsx index 37fb9071..f1952658 100644 --- a/src/components/campaign/leadership/LeadershipForm.tsx +++ b/src/components/campaign/leadership/LeadershipForm.tsx @@ -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 = ({
- - Celular (com DDD) - - {fieldError(state.fieldErrors, 'phone') ? ( - {fieldError(state.fieldErrors, 'phone')} - ) : null} - + E-mail (opcional) diff --git a/src/components/campaign/leadership/WizardLeadershipForm.tsx b/src/components/campaign/leadership/WizardLeadershipForm.tsx index 99235210..02288c42 100644 --- a/src/components/campaign/leadership/WizardLeadershipForm.tsx +++ b/src/components/campaign/leadership/WizardLeadershipForm.tsx @@ -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 = ({
- - Celular - - {fieldError(state.fieldErrors, 'phone') ? ( - {fieldError(state.fieldErrors, 'phone')} - ) : null} - + E-mail `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 + recordId?: number + recordIdField?: string + saveLabel?: string + cancelLabel?: string + className?: string +}) => { + const router = useRouter() + const [rows, setRows] = useState(() => { + 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(null) + const [isEditing, setIsEditing] = useState(!saveAction) + const rowsRef = useRef(rows) + rowsRef.current = rows + const appliedDefaultsRef = useRef(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 => { + 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) => ( +
+ updateRow(row.key, event.currentTarget.value)} + /> + {rows.length > 1 ? ( + <> + + + + ) : null} + +
+ )) + + const addButton = ( + + ) + + if (saveAction && !isEditing) { + return ( +
+
    + {(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => ( +
  • + {index + 1}. + + {row.value ? formatBrazilianPhoneInput(row.value) : '—'} + +
  • + ))} +
+ +
+ ) + } + + return ( +
+ {label} +
+ {rowInputs} + {addButton} +
+ {hasError ? ( + + {saveError ?? (Array.isArray(error) ? error[0] : error)} + + ) : null} + {saveAction ? ( +
+ + + {isPending ? : null} +
+ ) : null} +
+ ) +} diff --git a/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx b/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx index 3ca19172..f7671792 100644 --- a/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx @@ -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) => (

@@ -31,6 +33,7 @@ export const StateDeputyContactSection = ({ value={name} label="Nome" formAction={updateStateDeputyContactFormAction} + href={`/campanha/dobradinhas/${stateDeputyId}`} />

@@ -44,19 +47,21 @@ export const StateDeputyContactSection = ({ value={email} label="E-mail" formAction={updateStateDeputyContactFormAction} + readBehavior="copy" />
-
Telefone
+
+ Telefones (o primeiro é o principal) +
-
diff --git a/src/components/campaign/supporter/SupporterForm.tsx b/src/components/campaign/supporter/SupporterForm.tsx index 4673a924..f17c68d5 100644 --- a/src/components/campaign/supporter/SupporterForm.tsx +++ b/src/components/campaign/supporter/SupporterForm.tsx @@ -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 = ({ - - Celular * - - {phoneField.error ? ( - {phoneField.error} - ) : null} - + E-mail diff --git a/src/lib/formData.ts b/src/lib/formData.ts index b1981455..946170e5 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -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 diff --git a/src/lib/phone.ts b/src/lib/phone.ts index d3e01267..68a129b5 100644 --- a/src/lib/phone.ts +++ b/src/lib/phone.ts @@ -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) +} diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index e0aec974..768fd0e4 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -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 diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index d6445bb9..230ccfdc 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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 { + 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 { + // 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;`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 0081665e..a6d78dfe 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index d2d53137..3b72eaa0 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 { export interface ContactSelect { name?: T; email?: T; - phone?: T; + phones?: + | T + | { + value?: T; + id?: T; + }; gender?: T; state?: T; city?: T; diff --git a/src/utilities/activityLeadershipOptions.ts b/src/utilities/activityLeadershipOptions.ts index 7d7c61d4..748eede2 100644 --- a/src/utilities/activityLeadershipOptions.ts +++ b/src/utilities/activityLeadershipOptions.ts @@ -42,7 +42,7 @@ export const searchActivityLeadershipOptions = async ( { or: [ { 'contact.name': { contains: trimmed } }, - { 'contact.phone': { contains: digits || trimmed } }, + { 'contact.phones.value': { contains: digits || trimmed } }, ], }, ], diff --git a/src/utilities/activityResponsibleSearch.ts b/src/utilities/activityResponsibleSearch.ts index df9fcb5d..deb90368 100644 --- a/src/utilities/activityResponsibleSearch.ts +++ b/src/utilities/activityResponsibleSearch.ts @@ -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, diff --git a/src/utilities/activityViewModels.ts b/src/utilities/activityViewModels.ts index 9832d8e8..de005c8c 100644 --- a/src/utilities/activityViewModels.ts +++ b/src/utilities/activityViewModels.ts @@ -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, diff --git a/src/utilities/advisorData.ts b/src/utilities/advisorData.ts index c0258856..57667df0 100644 --- a/src/utilities/advisorData.ts +++ b/src/utilities/advisorData.ts @@ -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) ?? [], } } diff --git a/src/utilities/campaignInviteCreation.ts b/src/utilities/campaignInviteCreation.ts index c0742b1f..bc5e08d2 100644 --- a/src/utilities/campaignInviteCreation.ts +++ b/src/utilities/campaignInviteCreation.ts @@ -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, diff --git a/src/utilities/campaignInvitePageData.ts b/src/utilities/campaignInvitePageData.ts index 622139d0..b8fee5f7 100644 --- a/src/utilities/campaignInvitePageData.ts +++ b/src/utilities/campaignInvitePageData.ts @@ -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, }, diff --git a/src/utilities/campaignInviteRedemption.ts b/src/utilities/campaignInviteRedemption.ts index c67312c6..47667137 100644 --- a/src/utilities/campaignInviteRedemption.ts +++ b/src/utilities/campaignInviteRedemption.ts @@ -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, diff --git a/src/utilities/contactIdentity.ts b/src/utilities/contactIdentity.ts index 9cafaa03..34d6bbf2 100644 --- a/src/utilities/contactIdentity.ts +++ b/src/utilities/contactIdentity.ts @@ -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, diff --git a/src/utilities/homeSearch/searchHomeLeaderships.ts b/src/utilities/homeSearch/searchHomeLeaderships.ts index 4a2a02c2..64feb02c 100644 --- a/src/utilities/homeSearch/searchHomeLeaderships.ts +++ b/src/utilities/homeSearch/searchHomeLeaderships.ts @@ -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[] => { diff --git a/src/utilities/leaderContactsPageData.ts b/src/utilities/leaderContactsPageData.ts index f45fc8e6..97779ba9 100644 --- a/src/utilities/leaderContactsPageData.ts +++ b/src/utilities/leaderContactsPageData.ts @@ -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, diff --git a/src/utilities/leadership/leadershipData.ts b/src/utilities/leadership/leadershipData.ts index b086bf9e..a2c469f4 100644 --- a/src/utilities/leadership/leadershipData.ts +++ b/src/utilities/leadership/leadershipData.ts @@ -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), diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index 8ca04724..c8edfeaa 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -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, } diff --git a/src/utilities/people/personDelete.ts b/src/utilities/people/personDelete.ts index d59b703f..1061e5d7 100644 --- a/src/utilities/people/personDelete.ts +++ b/src/utilities/people/personDelete.ts @@ -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, diff --git a/src/utilities/signatureExport.ts b/src/utilities/signatureExport.ts index e45e2be8..9a88d7e2 100644 --- a/src/utilities/signatureExport.ts +++ b/src/utilities/signatureExport.ts @@ -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 diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts index be1e3574..95919ec2 100644 --- a/src/utilities/stateDeputyData.ts +++ b/src/utilities/stateDeputyData.ts @@ -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, diff --git a/src/utilities/supporter/supporterImportBulk.ts b/src/utilities/supporter/supporterImportBulk.ts index d5b3b322..d31847fa 100644 --- a/src/utilities/supporter/supporterImportBulk.ts +++ b/src/utilities/supporter/supporterImportBulk.ts @@ -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> => 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): number | null => { return Number.isSafeInteger(parsed) ? parsed : null } +const asCreatedContactID = (row: Record): 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> = [] + 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) } } } diff --git a/src/utilities/supporter/supporterListFilters.ts b/src/utilities/supporter/supporterListFilters.ts index fbc15fa7..f8528e96 100644 --- a/src/utilities/supporter/supporterListFilters.ts +++ b/src/utilities/supporter/supporterListFilters.ts @@ -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 } diff --git a/src/utilities/supporter/supporterListSqlFilters.ts b/src/utilities/supporter/supporterListSqlFilters.ts index 4c9300d0..722ad1f7 100644 --- a/src/utilities/supporter/supporterListSqlFilters.ts +++ b/src/utilities/supporter/supporterListSqlFilters.ts @@ -20,9 +20,14 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType { await expect(page.getByRole('heading', { name: 'Nova liderança' })).toBeVisible() 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({ diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index d865848b..b613c04c 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -641,7 +641,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, }, @@ -702,7 +702,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() diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 64f82b26..6407ce5a 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -185,7 +185,7 @@ class CampaignE2EOwnership { collection: 'contact', data: { name: contactName, - phone: this.phone(), + phones: [{ value: this.phone() }], state: 'BA', city: municipalities[0]?.name ?? 'Salvador', }, diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 81186c88..3ada076b 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -62,7 +62,9 @@ type EditorUserInput = { } type ConsentInput = Partial> type ContactInput = Partial< - Pick + Pick & { + phones: Array<{ value: string }> + } > type LeadershipInput = Partial< Pick< @@ -544,7 +546,7 @@ export class CampaignFixtures { or: [ { name: { contains: this.runID } }, { email: { contains: this.runID } }, - { phone: { contains: this.runID } }, + { 'phones.value': { contains: this.runID } }, ], }, depth: 0, @@ -656,11 +658,12 @@ export class CampaignFixtures { } async createContact(input: ContactInput = {}): Promise { + 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, @@ -668,12 +671,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). */ @@ -888,7 +892,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) } diff --git a/tests/int/campaignContactIdentity.int.spec.ts b/tests/int/campaignContactIdentity.int.spec.ts index 3a7a61e1..025d22e8 100644 --- a/tests/int/campaignContactIdentity.int.spec.ts +++ b/tests/int/campaignContactIdentity.int.spec.ts @@ -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 () => { diff --git a/tests/int/campaignFixtures.int.spec.ts b/tests/int/campaignFixtures.int.spec.ts index 4631a7ba..2185a945 100644 --- a/tests/int/campaignFixtures.int.spec.ts +++ b/tests/int/campaignFixtures.int.spec.ts @@ -217,7 +217,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', }, @@ -276,14 +276,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, }) } diff --git a/tests/int/campaignInvite.int.spec.ts b/tests/int/campaignInvite.int.spec.ts index 1875d0dd..7ffaa6b8 100644 --- a/tests/int/campaignInvite.int.spec.ts +++ b/tests/int/campaignInvite.int.spec.ts @@ -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]), + ) }, ) diff --git a/tests/int/campaignInviteUi.int.spec.ts b/tests/int/campaignInviteUi.int.spec.ts index 8a92c2be..3d20c037 100644 --- a/tests/int/campaignInviteUi.int.spec.ts +++ b/tests/int/campaignInviteUi.int.spec.ts @@ -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') diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index 5742d881..e60b9857 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -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', }) diff --git a/tests/int/campaignLeadershipActions.int.spec.ts b/tests/int/campaignLeadershipActions.int.spec.ts index 914636f2..75e4b560 100644 --- a/tests/int/campaignLeadershipActions.int.spec.ts +++ b/tests/int/campaignLeadershipActions.int.spec.ts @@ -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 diff --git a/tests/int/campaignMultiPhones.int.spec.ts b/tests/int/campaignMultiPhones.int.spec.ts new file mode 100644 index 00000000..428bc39a --- /dev/null +++ b/tests/int/campaignMultiPhones.int.spec.ts @@ -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) + }) +}) diff --git a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts index 207d9b48..7f50c832 100644 --- a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts +++ b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts @@ -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') diff --git a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts index 0de78fbe..deaa1365 100644 --- a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts +++ b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts @@ -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, diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts index bc647a98..3ba7ee4a 100644 --- a/tests/int/campaignSupporter.int.spec.ts +++ b/tests/int/campaignSupporter.int.spec.ts @@ -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 () => { diff --git a/tests/int/campaignUserPhone.int.spec.ts b/tests/int/campaignUserPhone.int.spec.ts index f8838fc1..f88911b4 100644 --- a/tests/int/campaignUserPhone.int.spec.ts +++ b/tests/int/campaignUserPhone.int.spec.ts @@ -29,7 +29,6 @@ const createEngagedPhoneAccessGraph = async ( ) => { const contact = await fixtures.createContact({ name: fixtures.value('Contato telefone'), - phone: fixtures.phone(), }) return fixtures.createLeadership({ contact, diff --git a/tests/int/collectionAccessLockdown.int.spec.ts b/tests/int/collectionAccessLockdown.int.spec.ts index df1001f1..f64acec4 100644 --- a/tests/int/collectionAccessLockdown.int.spec.ts +++ b/tests/int/collectionAccessLockdown.int.spec.ts @@ -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, diff --git a/tests/int/contactPhoneNormalization.int.spec.ts b/tests/int/contactPhoneNormalization.int.spec.ts index a9d94cf7..61ee973a 100644 --- a/tests/int/contactPhoneNormalization.int.spec.ts +++ b/tests/int/contactPhoneNormalization.int.spec.ts @@ -25,14 +25,14 @@ const contactPhoneHook = Contact.hooks?.beforeValidate?.[0] type ContactPhoneHookArgs = Parameters>[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({ 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.') + }) }) diff --git a/tests/int/personDelete.int.spec.ts b/tests/int/personDelete.int.spec.ts index edfc412f..4ea6b495 100644 --- a/tests/int/personDelete.int.spec.ts +++ b/tests/int/personDelete.int.spec.ts @@ -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 { diff --git a/tests/int/submitPetitionSignature.int.spec.ts b/tests/int/submitPetitionSignature.int.spec.ts index 1e8a984d..52ff75e1 100644 --- a/tests/int/submitPetitionSignature.int.spec.ts +++ b/tests/int/submitPetitionSignature.int.spec.ts @@ -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, diff --git a/tests/int/submitWhatsapp.int.spec.ts b/tests/int/submitWhatsapp.int.spec.ts index d28a2368..26b448e5 100644 --- a/tests/int/submitWhatsapp.int.spec.ts +++ b/tests/int/submitWhatsapp.int.spec.ts @@ -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, diff --git a/tests/int/supporterListFilters.int.spec.ts b/tests/int/supporterListFilters.int.spec.ts index 017cdd68..baee77f1 100644 --- a/tests/int/supporterListFilters.int.spec.ts +++ b/tests/int/supporterListFilters.int.spec.ts @@ -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' } }, ], }, ], diff --git a/tests/unit/signatureExport.unit.spec.ts b/tests/unit/signatureExport.unit.spec.ts index 22683456..c65569a8 100644 --- a/tests/unit/signatureExport.unit.spec.ts +++ b/tests/unit/signatureExport.unit.spec.ts @@ -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', }) diff --git a/tests/unit/supporterSchema.unit.spec.ts b/tests/unit/supporterSchema.unit.spec.ts index 61daed57..a07d66a1 100644 --- a/tests/unit/supporterSchema.unit.spec.ts +++ b/tests/unit/supporterSchema.unit.spec.ts @@ -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,