Merge pull request #665 from fsolla/C112-multiplos-telefones-por-pessoa
C112 — Múltiplos telefones por pessoa
This commit is contained in:
commit
c83bd779d2
83 changed files with 15116 additions and 389 deletions
71
docs/plans/multiplos-telefones-por-pessoa-impl.md
Normal file
71
docs/plans/multiplos-telefones-por-pessoa-impl.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Impl: Múltiplos telefones por pessoa
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #626
|
||||
Intenção: docs/plans/multiplos-telefones-por-pessoa.md
|
||||
Appetite restante: ~1–2 dias eng (intenção); sem cortes de aceite
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** toda pessoa (liderança, apoiador, dobradinha, assessor) tem N telefones na ficha; listas mostram 1 (o principal); ficha mostra todos; WhatsApp/convite/import usam o principal; busca por telefone acha por qualquer número; guardrail C111 (números compartilhados entre pessoas) preservado.
|
||||
- **O que NÃO negociar:** ordem = prioridade sem flag explícita; sem rótulos/tipos de telefone; import CSV com uma coluna; login da liderança continua o `username` (não muda); dedupe dentro da MESMA ficha bloqueia salvar; **UI pública intocada** — formulários de petição/WhatsApp (e o componente `PhoneInput`) não mudam; a mudança de dados entra só no mapeamento server-side das ações públicas.
|
||||
- **O que reavaliar:** a hipótese da intenção listava `src/lib/phone.ts`, view models e import — ok; mas **assessor** tem camada extra (o telefone mora em `campaignUser.phone`, espelhado na ficha) e **`Contact.phone` é lido pelo site público** (petição/WhatsApp) e por export — a intenção não citava esses dois.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C[Contact] -->|array field| P[phones: {value}[]]
|
||||
P -->|primary| H[primaryPhoneOf]
|
||||
H --> VM[view models / listas / wa.me / convite]
|
||||
W[formulários criação/edição] -->|PhonesFieldEditor| P
|
||||
S[busca/import/findOrCreate] -->|where phones.value| P
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — array field Payload `phones` com subcampo `value` na `Contact`, removendo `phone` (fonte única). Payload onde com `'phones.value': { equals/contains }` resolve busca/dedupe com JOIN nativo, `_order` preserva ordem = prioridade, e o admin UI ganha a edição de graça.
|
||||
**Rejeitadas:**
|
||||
|
||||
- B — `jsonb phones: string[]` (custom/JSON): perde query/index Payload; `supporterListSqlFilters` e `supporterImportBulk` teriam que viver de SQL cru e a busca Payload (supporter filters, pickers) ficaria sem where nativo. Pior contrato para o caso de uso mais comum.
|
||||
- C — collection join `contactPhone`: entidade demais para um campo sem rótulos/tipos; clareza zero de "principal" (teria `_order` escondido do mesmo jeito); admin group extra.
|
||||
- D — manter `phone` + `phones` desnormalizados: duas fontes de verdade sincronizando em N write paths; rejeitado por invariante (uma fonte).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`phones`** (`src/collections/Contact.ts`): array field `phones` (label "Telefones", subcampo `value` text 11 dígitos, `required: false`); remove o campo `phone`. `beforeValidate` da collection normaliza cada item (`normalizeBrazilianPhone` / regex 11 dígitos), filtra vazios, rejeita inválido com `BRAZILIAN_PHONE_INVALID_MESSAGE` e rejeita **duplicado dentro da ficha** (nova copy: `BRAZILIAN_PHONE_DUPLICATE_MESSAGE` = "Telefone repetido na ficha.").
|
||||
- **Migration** `add_contact_phones_array`: tabela gerada `contact_phones` (\_parent_id, \_order, value) + backfill manual `INSERT ... SELECT id, phone, 0 FROM contact WHERE phone IS NOT NULL` + `DROP COLUMN contact.phone` (derruba `contact_phone_idx` junto) + índice manual `contact_phones (value)` (Payload não indexa subcampo de array; o equals/in da busca precisa).
|
||||
- **`primaryPhoneOf`** (`src/lib/phone.ts`): `phones[0]?.value ?? null` — o único tradutor array→principal. Todos os readers de lista/ficha passam por ele (view models `leadershipData`, `stateDeputyData`, `peopleData`, `supporterViewModels`, `leaderContactsPageData`, `campaignInvitePageData`, `activityViewModels`, home search, `MunicipalityDossier`, `ContactCombobox`, `wizardLeadershipContract`).
|
||||
- **`setContactPhones`** (nova utility `src/utilities/contactPhones.ts`): read-modify-write transacional do array (usada pelas actions de edição inline "seta principal preservando o resto" e pelos editors de ficha); `normalizeContactPhones` (pure, compartilhada com o hook e com o zod).
|
||||
- **`PhonesFieldEditor`** (novo componente `src/components/campaign/shared/PhonesFieldEditor.tsx`): inputs mascarados repetíveis (reusa `FormattedInput` + `formatBrazilianPhoneInput`), remover por linha, "+ Adicionar telefone", setas ↑/↓ de reordenação (ordem = prioridade; sem arrasto — anti-goal). Serializa `phones` repetido no FormData (padrão `repeatedRelationshipFormValues`; novo `repeatedPhoneFormValues` em `src/lib/formData.ts`).
|
||||
- **Schemas** (`src/lib/schemas/`): **`contactSchema` permanece com `phone`** (só o público usa — petição/WhatsApp); as ações públicas mapeiam `phone → phones: [{ value }]` ao gravar a `Contact` (UI e zod públicos intocados). Nos schemas de campanha: `leadershipCreateSchema`/`wizard`/`supporter`/`leaderSupporter`/`invite` trocam `phone` por `phones` (array, min 1 nos fluxos que exigiam); `contactFieldUpdateSchema`: branch `field: 'phone'` passa a significar "escrever principal preservando o resto" e novo branch `field: 'phones'` (array completo) para os editors de ficha.
|
||||
- **Access / Consent:** nenhum (campo interno de `Contact`; sem Consent novo).
|
||||
- **UI:** Impeccable B — formulários de criação/edição + ficha + listas existentes; shape→craft→critique→polish no editor compartilhado; shells e células inline existentes (`CampaignInlineEditableCell`) permanecem para as listas (mostram o principal, editam o principal).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer schema+server** — campo array + hook + migration (backfill+drop+índice) + `primaryPhoneOf` + `findOrCreateContactByPhone` via `phones.value` + view models trocando para o principal + ações públicas mapeando `phone → phones[0]` (sem tocar UI/zod públicos) + `signatureExport` + seed/fixtures. **Spike aqui:** confirmar semântica do `where: { 'phones.value': { equals/contains } }` em Payload 3.82 (int spec) — se falhar, fallback é JOIN cru nos poucos sites Payload-where.
|
||||
2. **Write paths** — zod/schemas das 5 superfícies + actions (leadership create/wizard/inline, supporter create/tombstone LGPD, stateDeputy inline, leaderSupporter, convite redemption, sync `CampaignUser` (append se faltar), import CSV preview+bulk) + where de busca (`supporterListFilters`, `supporterListSqlFilters`, pickers de atividade, combobox).
|
||||
3. **UI** — `PhonesFieldEditor` + forms de criação (LeadershipForm, SupporterForm, LeaderContactForm, WizardLeadershipForm) + fichas (LeadershipContactSection, StateDeputyContactSection, "Dados de contato" do apoiador com editor novo, assessor ganha seção "Telefones" na ficha) + células inline inalteradas na lista.
|
||||
4. **Gates** — unit (primaryPhoneOf, normalize/dedupe, schemas, formData) + int (multi-telefone nas 5 superfícies, dedupe bloqueia, principal preservado no inline, import, convite, busca por qualquer número, tombstone substitui TODOS os telefones, sync append) + `pnpm gate:fast` + `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não adicionar busca por telefone nas listas que hoje só buscam por nome (lideranças, dobradinhas, pessoas, assessores) — o aceite é "a busca por telefone encontra por qualquer número" onde busca por telefone existe (apoiadores, pickers); adicionar nas outras é item futuro se a mesa pedir. **Gatilho de revisitação:** mesa perguntar "por que não acho a liderança pelo número na lista de lideranças".
|
||||
- Sem cap de quantidade de telefones; sem flag "principal"; sem arrasto (setas são suficientes para trocar prioridade).
|
||||
- `campaignUser.phone` permanece campo único da conta (canal de contato/login); não vira array.
|
||||
- Sem espelho ficha→conta (conta continua espelhando UNICAMENTE via sync existente conta→ficha, agora append).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Payload onde em subcampo de array** (`'phones.value'`): spike na Fase 1 com int spec; fallback JOIN cru documentado nos poucos sites.
|
||||
- **Migration destrutiva em prod (PII real):** backfill roda ANTES do drop no mesmo migration; `migrate:status` local + revisão do SQL no checklist de deploy.
|
||||
- **Churn de testes** (muitos specs leem `contact.phone`): helper de fixture `createContact` continua aceitando `phone` e mapeia internamente para `phones` — maioria dos specs int fica intocada; asserts com `where: { phone }` viram `where: { 'phones.value': … }`.
|
||||
- **Race do sync conta→ficha (append)**: sem transação garantida no hook; se o append colidir com edição concorrente, o dedupe fail-closed rejeita o save da conta (raro, mensagem clara).
|
||||
- **Inline edit de lista** apagar principal: "limpar o campo" remove o index 0 (o resto sobe); nunca zera os demais.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (N telefones, lista=principal, ficha=todos, wa.me/convite/import=principal, busca por qualquer, C111 preservado, dedupe interno)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (fonte única em `Contact`; transação+req nas escritas multi-collection; copy pt-BR; identificadores inglês)
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — acima
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ import { redirect } from 'next/navigation'
|
|||
import {
|
||||
removeSupporterData,
|
||||
setSupporterVoteIntention,
|
||||
updateSupporterContact,
|
||||
} from '@/app/(campaign)/campanha/actions/supporter'
|
||||
import { SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
|
||||
import { checkboxFormValue, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
repeatedPhoneFormValues,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE, BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import {
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
|
|
@ -21,6 +27,8 @@ import {
|
|||
} from '@/lib/schemas/supporter'
|
||||
import {
|
||||
mapCampaignFormActionError,
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
type CampaignFormErrorState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
|
||||
|
|
@ -41,6 +49,12 @@ const safeVoteIntentionMessages = [
|
|||
SUPPORTER_STAFF_MESSAGE,
|
||||
] as const
|
||||
|
||||
const safeContactMessages = [
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
] as const
|
||||
|
||||
const toMessageOnlyState = (mapped: CampaignFormErrorState<unknown>): { message?: string } => {
|
||||
const message = firstFormActionMessage(mapped)
|
||||
return message ? { message } : {}
|
||||
|
|
@ -98,3 +112,20 @@ export const removeSupporterDataFormAction = async (
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSupporterContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await updateSupporterContact({
|
||||
id: requiredRelationshipFormValue(formData, 'supporterId'),
|
||||
field: 'phones',
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
return { message: 'Telefones atualizados.' }
|
||||
},
|
||||
safeMessages: safeContactMessages,
|
||||
genericMessage: 'Não foi possível salvar os telefones.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import Link from 'next/link'
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import {
|
||||
setSupporterVoteIntentionFormAction,
|
||||
updateSupporterContactFormAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { ConsentText } from '@/components/campaign/shared/ConsentText'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
|
||||
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
|
||||
|
|
@ -112,7 +116,15 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
|
|||
<CardTitle className="text-base">Dados de contato</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p className="tabular-nums">{supporter.phoneDisplay || '—'}</p>
|
||||
<PhonesFieldEditor
|
||||
defaultValues={supporter.phones}
|
||||
label="Celulares"
|
||||
saveAction={updateSupporterContactFormAction}
|
||||
recordId={supporter.id}
|
||||
recordIdField="supporterId"
|
||||
saveLabel="Salvar"
|
||||
cancelLabel="Cancelar"
|
||||
/>
|
||||
{supporter.email ? <p>{supporter.email}</p> : null}
|
||||
<p>
|
||||
{supporter.municipalityName ? (
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
|
||||
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
|
@ -14,7 +15,11 @@ import { formatBrazilianPhoneInput } from '@/lib/phone'
|
|||
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
|
||||
import { loadAdvisorDetail } from '@/utilities/advisorData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
|
||||
import {
|
||||
sendAdvisorPasswordResetFormAction,
|
||||
updateAdvisorContactFormAction,
|
||||
updateAdvisorProfileFormAction,
|
||||
} from '../formActions'
|
||||
|
||||
export async function generateMetadata({ params }: AdvisorDetailPageProps) {
|
||||
const { id: rawId } = await params
|
||||
|
|
@ -115,6 +120,29 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
|
|||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="advisor-contact-title"
|
||||
className="flex flex-col gap-2 rounded-xl border p-4"
|
||||
>
|
||||
<h2 id="advisor-contact-title" className="text-base font-medium">
|
||||
Telefones da pessoa
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Todos os números da ficha — o primeiro é o principal (listas, WhatsApp e convites).
|
||||
</p>
|
||||
{advisor.contactID !== null ? (
|
||||
<PhonesFieldEditor
|
||||
defaultValues={advisor.fichaPhones}
|
||||
label="Telefones"
|
||||
saveAction={updateAdvisorContactFormAction}
|
||||
recordId={advisor.contactID}
|
||||
recordIdField="contactId"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Ficha de contato ainda não vinculada.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="advisor-password-title" className="flex flex-col gap-3">
|
||||
<h2 id="advisor-password-title" className="text-base font-medium">
|
||||
Acesso
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import {
|
|||
createAdvisor,
|
||||
sendAdvisorPasswordReset,
|
||||
setAdvisorMunicipalitiesBatch,
|
||||
updateAdvisorContactFicha,
|
||||
updateAdvisorProfile,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
|
|
@ -88,6 +90,26 @@ export const sendAdvisorPasswordResetFormAction = async (
|
|||
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
|
||||
})
|
||||
|
||||
/**
|
||||
* C112 — the advisor's ficha (Contact) phone list. The account keeps its own
|
||||
* single channel phone ("Conta"); this editor targets the person's ficha.
|
||||
*/
|
||||
export const updateAdvisorContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await updateAdvisorContactFicha({
|
||||
contactId: requiredRelationshipFormValue(formData, 'contactId'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
return { message: 'Telefones atualizados.' }
|
||||
},
|
||||
safeMessages: advisorSafeMessages,
|
||||
genericMessage: 'Não foi possível salvar os telefones.',
|
||||
})
|
||||
|
||||
export const createAdvisorFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
|
|||
stateDeputyId={stateDeputy.id}
|
||||
name={stateDeputy.name}
|
||||
email={stateDeputy.email}
|
||||
phone={stateDeputy.phone}
|
||||
phones={stateDeputy.phones}
|
||||
/>
|
||||
{stateDeputy.notes ? (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
|
|||
leadershipId={leadership.id}
|
||||
name={leadership.name}
|
||||
email={leadership.email}
|
||||
phone={leadership.phone}
|
||||
phones={leadership.phones}
|
||||
/>
|
||||
{leadership.stateDeputies.length > 0 ? (
|
||||
<StateDeputyChips deputies={leadership.stateDeputies} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import { revalidatePath } from 'next/cache'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
|
||||
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
|
||||
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
nullableRelationshipFormValue,
|
||||
optionalFormText,
|
||||
repeatedPhoneFormValues,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADER_SUPPORTER_ONLY_MESSAGE,
|
||||
leaderSupporterCreateSchema,
|
||||
|
|
@ -32,7 +36,7 @@ export type LeaderSupporterFormState = {
|
|||
|
||||
type LeaderSupporterFormValues = {
|
||||
name?: string
|
||||
phone?: string
|
||||
phones?: string[]
|
||||
city?: string
|
||||
municipality?: string
|
||||
}
|
||||
|
|
@ -89,7 +93,7 @@ export const createLeaderSupporterRecord = async (
|
|||
const { contactID, reused } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: data.phone,
|
||||
phones: data.phones,
|
||||
name: data.name,
|
||||
city: data.city,
|
||||
})
|
||||
|
|
@ -133,18 +137,17 @@ export const createLeaderSupporterFormAction = async (
|
|||
): Promise<LeaderSupporterFormState> => {
|
||||
const values: LeaderSupporterFormValues = {
|
||||
name: optionalFormText(formData, 'name'),
|
||||
phone: optionalFormText(formData, 'phone'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
city: optionalFormText(formData, 'city'),
|
||||
municipality: optionalFormText(formData, 'municipality'),
|
||||
}
|
||||
|
||||
return runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
|
||||
const municipality = nullableRelationshipFormValue(formData, 'municipality')
|
||||
const input = leaderSupporterCreateSchema.parse({
|
||||
name: values.name ?? '',
|
||||
phone,
|
||||
phones: values.phones ?? [],
|
||||
city: values.city,
|
||||
municipality,
|
||||
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Payload } from 'payload'
|
|||
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
|
||||
import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership'
|
||||
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import {
|
||||
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
|
|
@ -81,8 +82,8 @@ const isUniqueLeadershipConflict = (error: unknown): boolean => {
|
|||
}
|
||||
|
||||
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
|
||||
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phone'> & {
|
||||
phone?: string | null
|
||||
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phones'> & {
|
||||
phones?: string[]
|
||||
}
|
||||
|
||||
const createValidatedLeadershipRecord = async (
|
||||
|
|
@ -118,7 +119,7 @@ const createValidatedLeadershipRecord = async (
|
|||
const { contactID, reused } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: data.phone ?? null,
|
||||
phones: data.phones ?? [],
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
city: city ?? undefined,
|
||||
|
|
@ -254,18 +255,25 @@ export const updateLeadershipContactRecord = async (
|
|||
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
|
||||
}
|
||||
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
|
||||
if (data.field === 'name') {
|
||||
contactData.name = data.name
|
||||
} else if (data.field === 'email') {
|
||||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
if (data.phone) {
|
||||
contactData.phone = data.phone
|
||||
} else {
|
||||
contactData.phone = null
|
||||
}
|
||||
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
|
||||
// list untouched: the new number goes first (an earlier occurrence
|
||||
// moves, never duplicates) and clearing removes the primary.
|
||||
const contact = current.contact
|
||||
contactData.phones = reorderWithPrimaryPhone(
|
||||
typeof contact === 'object' && contact !== null ? contact.phones : null,
|
||||
data.phone,
|
||||
).map((value) => ({ value }))
|
||||
} else if (data.field === 'phones') {
|
||||
contactData.phones = data.phones.map((value) => ({ value }))
|
||||
}
|
||||
|
||||
// bypass: contact write is staff-scoped via leadership access check above.
|
||||
|
|
@ -336,7 +344,7 @@ const updateLeadershipWizardRecord = async (
|
|||
id: contactID,
|
||||
data: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
phones: data.phones.map((value) => ({ value })),
|
||||
email: data.email,
|
||||
},
|
||||
depth: 0,
|
||||
|
|
@ -370,7 +378,7 @@ const createLeadershipWizardRecord = async (
|
|||
const data = leadershipWizardCreateSchema.parse(input)
|
||||
return createValidatedLeadershipRecord(payload, actor, {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
phones: data.phones,
|
||||
email: data.email,
|
||||
municipalities: [data.municipalityId],
|
||||
exclusive: data.exclusive,
|
||||
|
|
@ -415,7 +423,7 @@ export const createMunicipalityLeadershipRecord = async (
|
|||
const data = municipalityLeadershipCreateSchema.parse(input)
|
||||
const leadership = await createValidatedLeadershipRecord(payload, actor, {
|
||||
name: data.name,
|
||||
phone: null,
|
||||
phones: [],
|
||||
municipalities: [data.municipalityId],
|
||||
email: undefined,
|
||||
exclusive: true,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
|
||||
import {
|
||||
|
|
@ -69,7 +70,7 @@ const createStateDeputyWithContact = async (
|
|||
data: {
|
||||
name: data.name,
|
||||
email: null,
|
||||
phone: null,
|
||||
phones: [],
|
||||
state: 'BA' as Contact['state'],
|
||||
city: null,
|
||||
},
|
||||
|
|
@ -165,7 +166,7 @@ export const updateStateDeputyContactRecord = async (
|
|||
const current = await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: data.id,
|
||||
depth: 0,
|
||||
depth: 1,
|
||||
select: { contact: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
|
|
@ -175,18 +176,24 @@ export const updateStateDeputyContactRecord = async (
|
|||
const contactID = relationshipId(current.contact)
|
||||
if (contactID === null) throw new Error(STATE_DEPUTY_INVALID_CONTACT_MESSAGE)
|
||||
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
if (data.field === 'name') {
|
||||
await assertStateDeputyNameAvailable(payload, req, data.name, data.id)
|
||||
contactData.name = data.name
|
||||
} else if (data.field === 'email') {
|
||||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
if (data.phone) {
|
||||
contactData.phone = data.phone
|
||||
} else {
|
||||
contactData.phone = null
|
||||
}
|
||||
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
|
||||
// list untouched (see the leadership sibling).
|
||||
const contact = current.contact
|
||||
contactData.phones = reorderWithPrimaryPhone(
|
||||
typeof contact === 'object' && contact !== null ? contact.phones : null,
|
||||
data.phone,
|
||||
).map((value) => ({ value }))
|
||||
} else if (data.field === 'phones') {
|
||||
contactData.phones = data.phones.map((value) => ({ value }))
|
||||
}
|
||||
|
||||
// Bypass: the StateDeputy row access above established staff scope.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { requireRelationshipId } from '@/lib/relationship'
|
||||
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
|
||||
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
|
||||
import {
|
||||
SUPPORTER_DUPLICATE_MESSAGE,
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
|
|
@ -37,6 +39,8 @@ const getFreshStaffActor = (
|
|||
req?: PayloadTransactionRequest,
|
||||
): Promise<CampaignUser> => reloadStaffActor(payload, actor, SUPPORTER_STAFF_MESSAGE, req)
|
||||
|
||||
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
|
||||
|
||||
const assertMunicipalityManagement = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -92,7 +96,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
|
|||
const { contactID, reused } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: data.phone,
|
||||
phones: data.phones,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
city: data.city,
|
||||
|
|
@ -183,6 +187,54 @@ export const setSupporterVoteIntention = async (input: unknown) => {
|
|||
return setSupporterVoteIntentionRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* C112 — the supporter ficha's phone list editor. Staff only (same supporter
|
||||
* access gate as every supporter write); only the `phones` branch of the
|
||||
* shared per-field schema is honored.
|
||||
*/
|
||||
export const updateSupporterContactRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactFieldUpdateInput,
|
||||
) => {
|
||||
const data = contactFieldUpdateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await getFreshStaffActor(payload, actor, req)
|
||||
const supporter = await assertCanManageSupporter(payload, currentActor, data.id, req)
|
||||
|
||||
const contactID = relationshipId(supporter.contact)
|
||||
if (contactID === null) throw new Error(SUPPORTER_STAFF_MESSAGE)
|
||||
|
||||
if (data.field !== 'phones') {
|
||||
throw new Error(INVALID_FIELD_MESSAGE)
|
||||
}
|
||||
|
||||
// bypass: the supporter row access above established staff scope.
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
data: { phones: data.phones.map((value) => ({ value })) },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
return supporter
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar o contato do apoiador.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const updateSupporterContact = async (input: ContactFieldUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const supporter = await updateSupporterContactRecord(payload, actor, input)
|
||||
revalidatePath(`/campanha/apoiadores/${supporter.id}`, 'page')
|
||||
return supporter
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV import pipeline — implementation lives in ./supporterImport. A top-level
|
||||
// `'use server'` module cannot re-export values (only async functions defined
|
||||
|
|
@ -275,7 +327,9 @@ const anonymizeContact = async (
|
|||
data: {
|
||||
name: 'Titular removido',
|
||||
email: null,
|
||||
phone: tombstonePhone,
|
||||
// The tombstone REPLACES every number — the remaining phones are the
|
||||
// person's PII and must not survive the anonymization (C112).
|
||||
phones: [{ value: tombstonePhone }],
|
||||
gender: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
|
|
|
|||
|
|
@ -224,28 +224,35 @@ export const previewSupporterImportText = async (
|
|||
if (candidatePhones.length > 0) {
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { in: candidatePhones } },
|
||||
where: { 'phones.value': { in: candidatePhones } },
|
||||
depth: 0,
|
||||
// No LIMIT: a phone can now legitimately match many fichas (C111), so a
|
||||
// finite limit would silently miss a shared phone past the cutoff and
|
||||
// the preview would promise rows the confirm then aborts.
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { phone: true },
|
||||
select: { phones: { value: true } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
// C111 — the phone is not unique: a CSV phone resolving to two or more
|
||||
// fichas cannot be matched to a person, so every row with that phone is
|
||||
// flagged for manual resolution (never a silent "last one wins").
|
||||
// flagged for manual resolution (never a silent "last one wins"). Keyed by
|
||||
// the CSV phone ITSELF, not the ficha's primary: the JOIN matched any
|
||||
// position, so a number carried as secondary by two fichas must be shared
|
||||
// here — exactly what the confirm's fail-closed check will abort on.
|
||||
const candidatePhoneSet = new Set(candidatePhones)
|
||||
const contactByPhone = new Map<string, number>()
|
||||
const sharedPhones = new Set<string>()
|
||||
for (const doc of contacts.docs) {
|
||||
if (!doc.phone) continue
|
||||
if (contactByPhone.has(doc.phone)) {
|
||||
sharedPhones.add(doc.phone)
|
||||
continue
|
||||
for (const entry of doc.phones ?? []) {
|
||||
const phone = entry.value
|
||||
if (!phone || !candidatePhoneSet.has(phone)) continue
|
||||
if (contactByPhone.has(phone)) {
|
||||
sharedPhones.add(phone)
|
||||
continue
|
||||
}
|
||||
contactByPhone.set(phone, doc.id)
|
||||
}
|
||||
contactByPhone.set(doc.phone, doc.id)
|
||||
}
|
||||
const contactIDs = [...contactByPhone.values()]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ const resolveContactForAccount = async ({
|
|||
const { contactID } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: typeof phone === 'string' && phone.length > 0 ? phone : null,
|
||||
phones: typeof phone === 'string' && phone.length > 0 ? [phone] : [],
|
||||
name,
|
||||
email: email && !isPlanilhaPlaceholderEmail(email) ? email : null,
|
||||
})
|
||||
|
|
@ -205,7 +205,11 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
|
|||
const linkedContactID = relationshipId(originalDoc?.contact)
|
||||
|
||||
if (linkedContactID !== null) {
|
||||
const contactData: { name?: string; email?: string | null; phone?: string } = {}
|
||||
const contactData: {
|
||||
name?: string
|
||||
email?: string | null
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
|
||||
if (data.name !== undefined && data.name !== originalDoc?.name) {
|
||||
contactData.name = data.name
|
||||
|
|
@ -216,14 +220,32 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
|
|||
}
|
||||
}
|
||||
// Clearing the account's phone never clears the ficha's: the ficha's
|
||||
// phone is the person's dedupe key across every join (supporter,
|
||||
// phones are the person's dedupe keys across every join (supporter,
|
||||
// leadership, dobradinha), and the account edit is not a ficha edit.
|
||||
// A changed account phone is APPENDED when the ficha lacks it (never
|
||||
// reordering the mesa's priority, never removing the previous number);
|
||||
// creation still seeds it as the primary via find-or-create.
|
||||
if (
|
||||
typeof data.phone === 'string' &&
|
||||
data.phone.length > 0 &&
|
||||
data.phone !== originalDoc?.phone
|
||||
) {
|
||||
contactData.phone = data.phone
|
||||
const contact = await req.payload.findByID({
|
||||
collection: 'contact',
|
||||
id: linkedContactID,
|
||||
depth: 0,
|
||||
select: { phones: { value: true } },
|
||||
// Intentional admin bypass: same account-scoped policy as the ficha
|
||||
// write below — the read only derives the append target.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const existingPhones = (contact.phones ?? [])
|
||||
.map((entry) => entry.value)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
if (!existingPhones.includes(data.phone)) {
|
||||
contactData.phones = [...existingPhones, data.phone].map((value) => ({ value }))
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(contactData).length === 0) return data
|
||||
|
|
|
|||
|
|
@ -1,11 +1,63 @@
|
|||
import { CitiesByState } from '@/lib/cities'
|
||||
import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone'
|
||||
import {
|
||||
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
normalizeBrazilianPhone,
|
||||
} from '@/lib/phone'
|
||||
import { trimmedText } from '@/lib/text'
|
||||
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
|
||||
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
type ContactPhoneValue = { value: string }
|
||||
|
||||
/**
|
||||
* Normalizes the ficha's phone list: each entry is a valid Brazilian mobile
|
||||
* (11 digits, `^[1-9]{2}9\d{8}$`), empty entries are dropped, and the same
|
||||
* number cannot be stored twice in the same ficha (the person already has it;
|
||||
* sharing a number BETWEEN fichas stays allowed — C111). `undefined` (admin
|
||||
* untouched) passes through; `null`/`''` collapse to an empty list.
|
||||
*/
|
||||
const normalizeContactPhones = (data: unknown): ContactPhoneValue[] => {
|
||||
const phones = Array.isArray(data) ? data : []
|
||||
const seen = new Set<string>()
|
||||
const normalized: ContactPhoneValue[] = []
|
||||
|
||||
for (const entry of phones) {
|
||||
if (entry === null || entry === undefined) continue
|
||||
const raw =
|
||||
typeof entry === 'object' && 'value' in (entry as Record<string, unknown>)
|
||||
? (entry as Record<string, unknown>).value
|
||||
: entry
|
||||
|
||||
if (raw === null || raw === undefined || raw === '') continue
|
||||
|
||||
const input = String(raw)
|
||||
let value: string | null
|
||||
if (/^\d{11}$/.test(input)) {
|
||||
if (/^[1-9]{2}9\d{8}$/.test(input)) {
|
||||
value = input
|
||||
} else {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
} else {
|
||||
value = normalizeBrazilianPhone(input)
|
||||
if (!value) {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new APIError(BRAZILIAN_PHONE_DUPLICATE_MESSAGE, 400)
|
||||
}
|
||||
seen.add(value)
|
||||
normalized.push({ value })
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
|
||||
data,
|
||||
operation,
|
||||
|
|
@ -57,27 +109,14 @@ export const Contact: CollectionConfig = {
|
|||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data }) => {
|
||||
if (!data || data.phone === undefined) return data
|
||||
if (!data || data.phones === undefined) return data
|
||||
|
||||
if (data.phone === null || data.phone === '') {
|
||||
data.phone = null
|
||||
if (data.phones === null || data.phones === '') {
|
||||
data.phones = []
|
||||
return data
|
||||
}
|
||||
|
||||
const input = String(data.phone)
|
||||
if (/^\d{11}$/.test(input)) {
|
||||
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const phone = normalizeBrazilianPhone(input)
|
||||
if (!phone) {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
|
||||
data.phone = phone
|
||||
data.phones = normalizeContactPhones(data.phones)
|
||||
return data
|
||||
},
|
||||
],
|
||||
|
|
@ -99,15 +138,22 @@ export const Contact: CollectionConfig = {
|
|||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
label: 'Celular',
|
||||
minLength: 11,
|
||||
maxLength: 11,
|
||||
name: 'phones',
|
||||
type: 'array',
|
||||
label: 'Telefones',
|
||||
// Optional at the collection level so name-only records (e.g. leaderships imported
|
||||
// from the projection sheet) can exist; UI flows still require it via zod schemas.
|
||||
required: false,
|
||||
index: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
label: 'Celular',
|
||||
minLength: 11,
|
||||
maxLength: 11,
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'gender',
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import {
|
|||
type LeaderSupporterFormState,
|
||||
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
|
|
@ -26,7 +26,6 @@ import {
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
|
||||
import { municipalityComboboxOptions } from '@/utilities/territory/territoryComboboxOptions'
|
||||
|
||||
|
|
@ -55,7 +54,6 @@ export const LeaderContactForm = ({
|
|||
|
||||
useCampaignFormSuccessToast(state)
|
||||
|
||||
const phoneField = errorProps(state.fieldErrors, 'phone')
|
||||
const nameField = errorProps(state.fieldErrors, 'name')
|
||||
const cityField = errorProps(state.fieldErrors, 'city')
|
||||
const municipalityField = errorProps(state.fieldErrors, 'municipality')
|
||||
|
|
@ -88,33 +86,19 @@ export const LeaderContactForm = ({
|
|||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={phoneField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="leader-contact-phone"
|
||||
name="phone"
|
||||
required
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
autoFocus
|
||||
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
aria-invalid={phoneField.invalid}
|
||||
aria-describedby={phoneField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{phoneField.error ? (
|
||||
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal) *"
|
||||
defaultValues={values?.phones ?? []}
|
||||
error={state.fieldErrors?.phones}
|
||||
/>
|
||||
|
||||
<Field data-invalid={nameField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
|
||||
<Input
|
||||
id="leader-contact-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
|
|
@ -148,7 +132,6 @@ export const LeaderContactForm = ({
|
|||
<NativeSelect
|
||||
id="leader-contact-municipality"
|
||||
name="municipality"
|
||||
required
|
||||
defaultValue={values?.municipality ?? ''}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
|
||||
>
|
||||
|
|
@ -193,7 +176,6 @@ export const LeaderContactForm = ({
|
|||
id="leader-contact-consent"
|
||||
name="consentAccepted"
|
||||
value="true"
|
||||
required
|
||||
aria-invalid={Boolean(consentError)}
|
||||
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
|
||||
type LeadershipContactSectionProps = {
|
||||
leadershipId: number
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
phones: string[]
|
||||
}
|
||||
|
||||
export const LeadershipContactSection = ({
|
||||
leadershipId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
phones,
|
||||
}: LeadershipContactSectionProps) => (
|
||||
<section aria-labelledby="leadership-contact-title" className="flex flex-col gap-3">
|
||||
<h2 id="leadership-contact-title" className="text-base font-medium">
|
||||
|
|
@ -50,16 +52,16 @@ export const LeadershipContactSection = ({
|
|||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Celular</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Celulares <span className="font-normal">(o primeiro é o principal)</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<CampaignInlineEditableCell
|
||||
<PhonesFieldEditor
|
||||
defaultValues={phones}
|
||||
label="Celulares"
|
||||
saveAction={updateLeadershipContactFormAction}
|
||||
recordId={leadershipId}
|
||||
recordIdField="leadershipId"
|
||||
field="phone"
|
||||
value={phone}
|
||||
label="Celular"
|
||||
formAction={updateLeadershipContactFormAction}
|
||||
readBehavior="copy"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useActionState } from 'react'
|
||||
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import {
|
||||
RelationMultiSelect,
|
||||
type RelationOption,
|
||||
|
|
@ -46,7 +47,6 @@ export const LeadershipForm = ({
|
|||
<Input
|
||||
id="leadership-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
className="min-h-11"
|
||||
|
|
@ -56,20 +56,12 @@ export const LeadershipForm = ({
|
|||
) : null}
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-phone">Celular (com DDD)</FieldLabel>
|
||||
<Input
|
||||
id="leadership-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
autoComplete="tel-national"
|
||||
className="min-h-11"
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'phone') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal)"
|
||||
error={fieldError(state.fieldErrors, 'phones')}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-email">E-mail (opcional)</FieldLabel>
|
||||
<Input id="leadership-email" name="email" type="email" className="min-h-11" />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
updateLeadershipWizardFormAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
|
|
@ -66,7 +67,6 @@ export const WizardLeadershipForm = ({
|
|||
ref={nameRef}
|
||||
id="wizard-leadership-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
defaultValue={leadership?.name ?? ''}
|
||||
|
|
@ -78,21 +78,13 @@ export const WizardLeadershipForm = ({
|
|||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wizard-leadership-phone">Celular</FieldLabel>
|
||||
<Input
|
||||
id="wizard-leadership-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
autoComplete="tel-national"
|
||||
defaultValue={leadership?.phone ?? ''}
|
||||
className="min-h-11"
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'phone') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal)"
|
||||
defaultValues={leadership?.phones ?? []}
|
||||
error={fieldError(state.fieldErrors, 'phones')}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wizard-leadership-email">E-mail</FieldLabel>
|
||||
<Input
|
||||
|
|
|
|||
285
src/components/campaign/shared/PhonesFieldEditor.tsx
Normal file
285
src/components/campaign/shared/PhonesFieldEditor.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
'use client'
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type PhoneRow = { key: string; value: string }
|
||||
|
||||
let rowKey = 0
|
||||
const nextRowKey = (): string => `phone-${++rowKey}`
|
||||
const EMPTY_ROW_KEY = 'phone-empty'
|
||||
|
||||
const displayValue = (value: string): string => formatBrazilianPhoneInput(value)
|
||||
|
||||
/**
|
||||
* The repeatable phone list editor (C112): one masked input per number, order
|
||||
* = priority (first = primary), with remove and ↑/↓ reorder — no labels, no
|
||||
* drag (product cut). In a create/edit form it renders plain inputs named
|
||||
* `phones` for the form action to collect; with `saveAction` it becomes the
|
||||
* ficha's inline editor (Salvar/Cancelar + refresh on success).
|
||||
*/
|
||||
export const PhonesFieldEditor = ({
|
||||
name = 'phones',
|
||||
defaultValues = [],
|
||||
minRows = 0,
|
||||
label = 'Celulares',
|
||||
error,
|
||||
saveAction,
|
||||
recordId,
|
||||
recordIdField,
|
||||
saveLabel = 'Salvar',
|
||||
cancelLabel = 'Cancelar',
|
||||
className,
|
||||
}: {
|
||||
name?: string
|
||||
defaultValues?: string[]
|
||||
/** Rows below this count cannot be removed (create flows that require a phone). */
|
||||
minRows?: number
|
||||
label?: string
|
||||
error?: string | string[] | undefined
|
||||
/** Inline mode: submits the full list through the per-surface contact update action. */
|
||||
saveAction?: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
recordId?: number
|
||||
recordIdField?: string
|
||||
saveLabel?: string
|
||||
cancelLabel?: string
|
||||
className?: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [rows, setRows] = useState<PhoneRow[]>(() => {
|
||||
const initial = defaultValues.map((value) => ({ key: nextRowKey(), value }))
|
||||
while (initial.length < minRows) initial.push({ key: nextRowKey(), value: '' })
|
||||
return initial
|
||||
})
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(!saveAction)
|
||||
const rowsRef = useRef(rows)
|
||||
rowsRef.current = rows
|
||||
const appliedDefaultsRef = useRef<string[]>(defaultValues)
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveAction) return
|
||||
// Apply the server values only when they actually changed (a post-save
|
||||
// refresh or an unrelated refresh of the page must not drop what the
|
||||
// user is typing).
|
||||
if (defaultValues.join('|') === appliedDefaultsRef.current.join('|')) return
|
||||
appliedDefaultsRef.current = defaultValues
|
||||
setRows((current) => {
|
||||
const existing = new Map(current.map((row) => [row.value, row]))
|
||||
const next = defaultValues.map((value) => existing.get(value) ?? { key: nextRowKey(), value })
|
||||
return next.length > 0 || current.length === 0 ? next : current
|
||||
})
|
||||
}, [defaultValues, saveAction])
|
||||
|
||||
const updateRow = (key: string, value: string): void => {
|
||||
setRows((current) => current.map((row) => (row.key === key ? { ...row, value } : row)))
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((current) => [...current, { key: nextRowKey(), value: '' }])
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const removeRow = (key: string): void => {
|
||||
setRows((current) =>
|
||||
current.length > minRows ? current.filter((row) => row.key !== key) : current,
|
||||
)
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const moveRow = (index: number, direction: -1 | 1): void => {
|
||||
setRows((current) => {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= current.length) return current
|
||||
const next = [...current]
|
||||
;[next[index], next[target]] = [next[target]!, next[index]!]
|
||||
return next
|
||||
})
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const errorId = 'phones-field-error'
|
||||
const hasError = saveError !== null || (Array.isArray(error) ? error.length > 0 : Boolean(error))
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!saveAction || recordId === undefined) return
|
||||
const formData = new FormData()
|
||||
if (recordIdField) formData.set(recordIdField, String(recordId))
|
||||
formData.set('field', 'phones')
|
||||
for (const row of rowsRef.current) {
|
||||
formData.append(name, row.value)
|
||||
}
|
||||
setIsPending(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const result = await saveAction({}, formData)
|
||||
setIsPending(false)
|
||||
if (result.status === 'success') {
|
||||
setIsEditing(false)
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
const message =
|
||||
result.fieldErrors?.phones?.[0] ??
|
||||
result.fieldErrors?.[name]?.[0] ??
|
||||
result.fieldErrors?.form?.[0] ??
|
||||
result.message
|
||||
const refusal = message ?? 'Não foi possível salvar.'
|
||||
setSaveError(refusal)
|
||||
toast.error(refusal)
|
||||
} catch {
|
||||
setIsPending(false)
|
||||
const message = 'Não foi possível salvar. Tente novamente.'
|
||||
setSaveError(message)
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const rowInputs = rows.map((row, index) => (
|
||||
<div key={row.key} className="flex items-center gap-1.5">
|
||||
<FormattedInput
|
||||
name={name}
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
aria-label={`Celular ${index + 1}`}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={hasError ? `${errorId}` : undefined}
|
||||
value={displayValue(row.value)}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
className={campaignInlineInputClassName}
|
||||
onChange={(event) => updateRow(row.key, event.currentTarget.value)}
|
||||
/>
|
||||
{rows.length > 1 ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label="Mover para cima (torna principal antes dos demais)"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveRow(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label="Mover para baixo"
|
||||
disabled={index === rows.length - 1}
|
||||
onClick={() => moveRow(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label={`Remover celular ${index + 1}`}
|
||||
disabled={rows.length <= minRows}
|
||||
onClick={() => removeRow(row.key)}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
|
||||
const addButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 w-fit shrink-0 justify-start px-2 text-sm text-muted-foreground"
|
||||
aria-label="Adicionar telefone"
|
||||
onClick={addRow}
|
||||
>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
Adicionar telefone
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (saveAction && !isEditing) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ul className="flex flex-col gap-1.5" aria-label={label}>
|
||||
{(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => (
|
||||
<li key={row.key} className="flex items-center gap-1.5 text-sm tabular-nums">
|
||||
<span className="w-6 text-right text-muted-foreground">{index + 1}.</span>
|
||||
<span className="min-w-40">
|
||||
{row.value ? formatBrazilianPhoneInput(row.value) : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-1 size-11"
|
||||
aria-label={`Editar ${label.toLowerCase()}`}
|
||||
onClick={() => {
|
||||
setIsEditing(true)
|
||||
setSaveError(null)
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Editar</span>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rowInputs}
|
||||
{addButton}
|
||||
</div>
|
||||
{hasError ? (
|
||||
<FieldError id={errorId}>
|
||||
{saveError ?? (Array.isArray(error) ? error[0] : error)}
|
||||
</FieldError>
|
||||
) : null}
|
||||
{saveAction ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button type="button" disabled={isPending} onClick={() => void save()}>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setSaveError(null)
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
{isPending ? <Spinner className="size-4" aria-label="Salvando telefones" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
|
||||
type StateDeputyContactSectionProps = {
|
||||
stateDeputyId: number
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
phones: string[]
|
||||
}
|
||||
|
||||
export const StateDeputyContactSection = ({
|
||||
stateDeputyId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
phones,
|
||||
}: StateDeputyContactSectionProps) => (
|
||||
<section aria-labelledby="state-deputy-contact-title" className="flex flex-col gap-3">
|
||||
<h2 id="state-deputy-contact-title" className="text-base font-medium">
|
||||
|
|
@ -31,6 +33,7 @@ export const StateDeputyContactSection = ({
|
|||
value={name}
|
||||
label="Nome"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
href={`/campanha/dobradinhas/${stateDeputyId}`}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -44,19 +47,21 @@ export const StateDeputyContactSection = ({
|
|||
value={email}
|
||||
label="E-mail"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
readBehavior="copy"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Telefone</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Telefones <span className="font-normal">(o primeiro é o principal)</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<CampaignInlineEditableCell
|
||||
<PhonesFieldEditor
|
||||
defaultValues={phones}
|
||||
label="Telefones"
|
||||
saveAction={updateStateDeputyContactFormAction}
|
||||
recordId={stateDeputyId}
|
||||
recordIdField="stateDeputyId"
|
||||
field="phone"
|
||||
value={phone}
|
||||
label="Telefone"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { useActionState, useState } from 'react'
|
|||
|
||||
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
|
|
@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input'
|
|||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
|
||||
|
|
@ -118,7 +117,6 @@ export const SupporterForm = ({
|
|||
})
|
||||
|
||||
const nameField = errorProps(state.fieldErrors, 'name')
|
||||
const phoneField = errorProps(state.fieldErrors, 'phone')
|
||||
const emailField = errorProps(state.fieldErrors, 'email')
|
||||
const cityField = errorProps(state.fieldErrors, 'city')
|
||||
const municipalityField = errorProps(state.fieldErrors, 'municipality')
|
||||
|
|
@ -140,7 +138,6 @@ export const SupporterForm = ({
|
|||
<Input
|
||||
id="supporter-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
|
|
@ -153,25 +150,12 @@ export const SupporterForm = ({
|
|||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={phoneField.invalid}>
|
||||
<FieldLabel htmlFor="supporter-phone">Celular *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="supporter-phone"
|
||||
name="phone"
|
||||
required
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
aria-invalid={phoneField.invalid}
|
||||
aria-describedby={phoneField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{phoneField.error ? (
|
||||
<FieldError id="supporter-phone-error">{phoneField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal)"
|
||||
error={fieldError(state.fieldErrors, 'phones')}
|
||||
/>
|
||||
|
||||
<Field data-invalid={emailField.invalid}>
|
||||
<FieldLabel htmlFor="supporter-email">E-mail</FieldLabel>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CitiesByState } from '@/lib/cities'
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
|
|
@ -44,6 +45,20 @@ const nullableContactPhone = contactPhoneInput.transform((value) =>
|
|||
value === '' || value === undefined ? null : value,
|
||||
)
|
||||
|
||||
/**
|
||||
* The ficha's phone list: valid mobiles, never the same number twice within
|
||||
* the SAME person (C112 — the mesa blocks it at the form); sharing a number
|
||||
* BETWEEN persons stays free (C111). Order = priority (first = primary).
|
||||
*/
|
||||
export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones, context) => {
|
||||
if (new Set(phones).size !== phones.length) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** Per-field Contact write shared by campaign person joins. */
|
||||
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
||||
z.object({
|
||||
|
|
@ -61,6 +76,11 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
|||
field: z.literal('phone'),
|
||||
phone: nullableContactPhone,
|
||||
}),
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('phones'),
|
||||
phones: contactPhonesSchema,
|
||||
}),
|
||||
])
|
||||
|
||||
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { contactFieldUpdateSchema } from '@/lib/schemas/contact'
|
||||
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
import {
|
||||
brazilianMobile,
|
||||
optionalPersistedEmail,
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -91,7 +90,7 @@ export const leadershipCreateSchema = z.object({
|
|||
organizations: organizationsArraySchema.optional(),
|
||||
stateDeputies: stateDeputiesArraySchema.optional(),
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
email: optionalPersistedEmail,
|
||||
gender: z.enum(leadershipGenders).optional(),
|
||||
exclusive: z.boolean().default(true),
|
||||
|
|
@ -113,7 +112,7 @@ export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpd
|
|||
|
||||
const leadershipWizardFieldsSchema = z.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
email: optionalPersistedEmail,
|
||||
exclusive: z.boolean().default(true),
|
||||
supportStatus: z.enum(leadershipSupportStatuses).default('a_abordar'),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
import {
|
||||
brazilianMobile,
|
||||
optionalPersistedEmail,
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -70,7 +70,7 @@ const optionalBahiaCity = z
|
|||
export const supporterCreateSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
email: optionalPersistedEmail,
|
||||
city: optionalBahiaCity,
|
||||
municipality: positiveRelationshipId.optional(),
|
||||
|
|
@ -108,7 +108,7 @@ export const supporterImportConfirmSchema = z.object({
|
|||
|
||||
export const leaderSupporterCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
city: optionalBahiaCity,
|
||||
municipality: positiveRelationshipId,
|
||||
consentAccepted: z.literal(true, {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { SupportStatus } from '@/lib/schemas/leadership'
|
|||
export type WizardLeadershipTileViewModel = {
|
||||
id: number
|
||||
name: string
|
||||
/** Every number of the ficha, order = priority (C112). */
|
||||
phones: string[]
|
||||
phone: string | null
|
||||
email: string | null
|
||||
supportStatus: SupportStatus | null
|
||||
|
|
|
|||
13337
src/migrations/20260811_111828_add_contact_phones_array.json
Normal file
13337
src/migrations/20260811_111828_add_contact_phones_array.json
Normal file
File diff suppressed because it is too large
Load diff
41
src/migrations/20260811_111828_add_contact_phones_array.ts
Normal file
41
src/migrations/20260811_111828_add_contact_phones_array.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
// Hand-edited on top of the generated statements: the generator re-emitted
|
||||
// `campaign_user.contact_id` / `leadership_rels.campaign_user_id` because the
|
||||
// 20260809_204728 snapshot (.json) does not reflect its hand-written SQL —
|
||||
// those columns already exist in every environment (C99/C100). Kept here: the
|
||||
// `contact_phones` join table (order = priority, index on `value`), a backfill
|
||||
// of the existing single `phone` into `phones[0]` BEFORE the column drops, and
|
||||
// the drop of `contact.phone` (+ its index). Production holds real PII: the
|
||||
// backfill is lossless, the drop happens only after it.
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "contact_phones" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"value" varchar
|
||||
);
|
||||
|
||||
ALTER TABLE "contact_phones" ADD CONSTRAINT "contact_phones_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "contact_phones_order_idx" ON "contact_phones" USING btree ("_order");
|
||||
CREATE INDEX "contact_phones_parent_id_idx" ON "contact_phones" USING btree ("_parent_id");
|
||||
CREATE INDEX "contact_phones_value_idx" ON "contact_phones" USING btree ("value");
|
||||
INSERT INTO "contact_phones" ("id", "_parent_id", "_order", "value")
|
||||
SELECT gen_random_uuid()::text, "id", 0, "phone" FROM "contact" WHERE "phone" IS NOT NULL;
|
||||
DROP INDEX "contact_phone_idx";
|
||||
ALTER TABLE "contact" DROP COLUMN "phone";`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
// Rollback is lossy BY DESIGN: only the primary (`_order` 0) is restored to
|
||||
// `contact.phone` — every appended number is discarded. Rollbacks are
|
||||
// best-effort here (C111-era policy); the forward path is lossless.
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "contact" ADD COLUMN "phone" varchar;
|
||||
UPDATE "contact" SET "phone" = "contact_phones"."value"
|
||||
FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."_order" = 0;
|
||||
CREATE INDEX "contact_phone_idx" ON "contact" USING btree ("phone");
|
||||
ALTER TABLE "contact_phones" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "contact_phones" CASCADE;`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -298,7 +298,12 @@ export interface Contact {
|
|||
id: number;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
phones?:
|
||||
| {
|
||||
value?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
|
||||
state:
|
||||
| 'AC'
|
||||
|
|
@ -1977,7 +1982,12 @@ export interface PetitionSelect<T extends boolean = true> {
|
|||
export interface ContactSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
email?: T;
|
||||
phone?: T;
|
||||
phones?:
|
||||
| T
|
||||
| {
|
||||
value?: T;
|
||||
id?: T;
|
||||
};
|
||||
gender?: T;
|
||||
state?: T;
|
||||
city?: T;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const searchActivityLeadershipOptions = async (
|
|||
{
|
||||
or: [
|
||||
{ 'contact.name': { contains: trimmed } },
|
||||
{ 'contact.phone': { contains: digits || trimmed } },
|
||||
{ 'contact.phones.value': { contains: digits || trimmed } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ?? [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'server-only'
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { sql } from '@payloadcms/db-postgres'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
|
|
@ -43,6 +45,7 @@ type DrizzleTx = {
|
|||
}
|
||||
|
||||
const CONTACT_TABLE = 'contact'
|
||||
const CONTACT_PHONES_TABLE = 'contact_phones'
|
||||
const SUPPORTER_TABLE = 'supporter'
|
||||
|
||||
/**
|
||||
|
|
@ -68,13 +71,15 @@ const SUPPORTER_COLUMNS = [
|
|||
'updatedAt',
|
||||
] as const
|
||||
|
||||
/** The `Contact.phones` join table (C112): order = priority, value = the number. */
|
||||
const CONTACT_PHONES_COLUMNS = ['_order', '_parentID', 'value'] as const
|
||||
|
||||
const buildContactRows = (
|
||||
rows: SupporterImportBulkRow[],
|
||||
now: string,
|
||||
): Array<Record<string, unknown>> =>
|
||||
rows.map((row) => ({
|
||||
name: row.nome,
|
||||
phone: row.telefone,
|
||||
email: null,
|
||||
state: 'BA',
|
||||
city: row.municipio ?? null,
|
||||
|
|
@ -128,6 +133,12 @@ const asContactID = (row: Record<string, unknown>): number | null => {
|
|||
return Number.isSafeInteger(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const asCreatedContactID = (row: Record<string, unknown>): number | null => {
|
||||
const value = row.id
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isSafeInteger(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-insert contacts and municipality-less supporters for a CSV import inside the
|
||||
* caller's Payload transaction. Phone advisory locks MUST already be held for
|
||||
|
|
@ -159,8 +170,10 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
|
||||
const tables = getDrizzleTables(payload)
|
||||
const contactTable = requireTable(tables, CONTACT_TABLE)
|
||||
const contactPhonesTable = requireTable(tables, CONTACT_PHONES_TABLE)
|
||||
const supporterTable = requireTable(tables, SUPPORTER_TABLE)
|
||||
assertDrizzleColumns(supporterTable, SUPPORTER_TABLE, SUPPORTER_COLUMNS)
|
||||
assertDrizzleColumns(contactPhonesTable, CONTACT_PHONES_TABLE, CONTACT_PHONES_COLUMNS)
|
||||
|
||||
const database = await getPostgresTransactionDatabase(payload, req)
|
||||
const tx = database as unknown as DrizzleTx
|
||||
|
|
@ -172,10 +185,12 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
for (const phoneBatch of chunk(phones, INSERT_CHUNK_SIZE)) {
|
||||
const existingContactRows = drizzleResultRows(
|
||||
await tx.execute(
|
||||
sql`SELECT "id", "phone" FROM "contact" WHERE "phone" IN (${sql.join(
|
||||
phoneBatch.map((phone) => sql`${phone}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
sql`SELECT "contact"."id" AS "id", "contact_phones"."value" AS "phone" FROM "contact"
|
||||
JOIN "contact_phones" ON "contact_phones"."_parent_id" = "contact"."id"
|
||||
WHERE "contact_phones"."value" IN (${sql.join(
|
||||
phoneBatch.map((phone) => sql`${phone}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
for (const row of existingContactRows) {
|
||||
|
|
@ -205,9 +220,18 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
const newRows = [...newContactByPhone.values()]
|
||||
for (const batch of chunk(newRows, INSERT_CHUNK_SIZE)) {
|
||||
const created = await tx.insert(contactTable).values(buildContactRows(batch, now)).returning()
|
||||
for (const row of created) {
|
||||
const inserted = asPhoneKeyedRow(row)
|
||||
if (inserted) contactIdByPhone.set(inserted.phone, inserted.id)
|
||||
// The phones join-table rows follow the inserted contacts: each new
|
||||
// ficha starts with its CSV number as the single (primary) phone.
|
||||
const phoneRows: Array<Record<string, unknown>> = []
|
||||
created.forEach((row, index) => {
|
||||
const insertedID = asCreatedContactID(row)
|
||||
const telefone = batch[index]?.telefone
|
||||
if (insertedID === null || !telefone) return
|
||||
contactIdByPhone.set(telefone, insertedID)
|
||||
phoneRows.push({ id: randomUUID(), _parentID: insertedID, _order: 0, value: telefone })
|
||||
})
|
||||
if (phoneRows.length > 0) {
|
||||
await tx.insert(contactPhonesTable).values(phoneRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -20,9 +20,14 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType<typeof
|
|||
]
|
||||
|
||||
if (terms.normalizedPhone) {
|
||||
searchTerms.push(sql`"contact"."phone" = ${terms.normalizedPhone}`)
|
||||
// Matches ANY of the ficha's numbers (C112) — EXISTS over the join table.
|
||||
searchTerms.push(
|
||||
sql`EXISTS (SELECT 1 FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."value" = ${terms.normalizedPhone})`,
|
||||
)
|
||||
} else if (terms.phoneDigits) {
|
||||
searchTerms.push(sql`"contact"."phone" ILIKE ${`%${terms.phoneDigits}%`}`)
|
||||
searchTerms.push(
|
||||
sql`EXISTS (SELECT 1 FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."value" ILIKE ${`%${terms.phoneDigits}%`})`,
|
||||
)
|
||||
}
|
||||
|
||||
return sql`(${sql.join(searchTerms, sql` OR `)})`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { territoryForCity } from '@/lib/bahiaTerritories'
|
||||
import { formatBrazilianPhoneInput } from '@/lib/phone'
|
||||
import { formatBrazilianPhoneInput, phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
|
||||
import { isPopulatedRelationship } from '@/lib/relationship'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import type { CampaignUser, Contact, Municipality, Supporter } from '@/payload-types'
|
||||
|
|
@ -27,6 +27,8 @@ export type SupporterDetailViewModel = {
|
|||
name: string
|
||||
phone: string
|
||||
phoneDisplay: string
|
||||
/** Every number of the person, order = priority (primary first). */
|
||||
phones: string[]
|
||||
email: string | null
|
||||
city: string | null
|
||||
territory: string | null
|
||||
|
|
@ -57,7 +59,7 @@ export const toSupporterListItemViewModel = (
|
|||
return {
|
||||
id: supporter.id,
|
||||
name: contact?.name ?? 'Contato sem nome',
|
||||
phone: contact?.phone ?? '',
|
||||
phone: primaryPhoneOf(contact?.phones) ?? '',
|
||||
email: contact?.email ?? null,
|
||||
city: contact?.city ?? null,
|
||||
municipalityName: municipality?.name ?? null,
|
||||
|
|
@ -73,13 +75,15 @@ export const toSupporterDetailViewModel = (supporter: Supporter): SupporterDetai
|
|||
? supporter.createdBy
|
||||
: null
|
||||
const city = contact?.city ?? null
|
||||
const phone = contact?.phone ?? ''
|
||||
const phones = phoneValuesOf(contact?.phones)
|
||||
const phone = phones[0] ?? ''
|
||||
|
||||
return {
|
||||
id: supporter.id,
|
||||
name: contact?.name ?? 'Contato sem nome',
|
||||
phone,
|
||||
phoneDisplay: phone ? formatBrazilianPhoneInput(phone) : '',
|
||||
phones,
|
||||
email: contact?.email ?? null,
|
||||
city,
|
||||
territory: city ? (territoryForCity(city) ?? null) : null,
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
|
|||
}).toPass({ timeout: 15_000 })
|
||||
|
||||
await page.getByLabel('Nome').fill(`Liderança B70 ${suffix}`)
|
||||
await page.getByLabel('Celular').fill(`719${suffix}`)
|
||||
await page.getByRole('textbox', { name: 'Celular 1' }).fill(`719${suffix}`)
|
||||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({
|
||||
|
|
|
|||
|
|
@ -644,7 +644,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
collection: 'contact',
|
||||
data: {
|
||||
name: fixtures.value('Contato Liderança'),
|
||||
phone: leaderPhone,
|
||||
phones: [{ value: leaderPhone }],
|
||||
state: 'BA',
|
||||
city: administered.name,
|
||||
},
|
||||
|
|
@ -705,7 +705,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const supporterName = fixtures.value('Apoiador Liderança')
|
||||
const supporterPhone = fixtures.phone()
|
||||
await page.getByLabel('Nome *').fill(supporterName)
|
||||
await page.getByLabel('Celular *').fill(supporterPhone)
|
||||
await page.getByRole('textbox', { name: 'Celular 1' }).fill(supporterPhone)
|
||||
// Radix checkbox: a pre-hydration click is a silent no-op (B13/B17 flake).
|
||||
await checkRadixWhenHydrated(page, 'A pessoa autorizou o cadastro *')
|
||||
await page.getByRole('button', { name: 'Cadastrar contato' }).click()
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export class CampaignE2EOwnership {
|
|||
collection: 'contact',
|
||||
data: {
|
||||
name: contactName,
|
||||
phone: this.phone(),
|
||||
phones: [{ value: this.phone() }],
|
||||
state: 'BA',
|
||||
city: municipalities[0]?.name ?? 'Salvador',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ type EditorUserInput = {
|
|||
}
|
||||
type ConsentInput = Partial<Pick<Consent, 'key' | 'text'>>
|
||||
type ContactInput = Partial<
|
||||
Pick<Contact, 'name' | 'email' | 'phone' | 'gender' | 'state' | 'city' | 'postalCode'>
|
||||
Pick<Contact, 'name' | 'email' | 'gender' | 'state' | 'city' | 'postalCode'> & {
|
||||
phones: Array<{ value: string }>
|
||||
}
|
||||
>
|
||||
type LeadershipInput = Partial<
|
||||
Pick<
|
||||
|
|
@ -601,7 +603,7 @@ export class CampaignFixtures {
|
|||
or: [
|
||||
{ name: { contains: this.runID } },
|
||||
{ email: { contains: this.runID } },
|
||||
{ phone: { contains: this.runID } },
|
||||
{ 'phones.value': { contains: this.runID } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
|
|
@ -713,11 +715,12 @@ export class CampaignFixtures {
|
|||
}
|
||||
|
||||
async createContact(input: ContactInput = {}): Promise<Contact & { phone: string }> {
|
||||
const phone = input.phones?.[0]?.value ?? this.phone()
|
||||
const contact = await this.rootPayload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: this.value('Contato'),
|
||||
phone: this.phone(),
|
||||
phones: [{ value: phone }],
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
...input,
|
||||
|
|
@ -725,12 +728,13 @@ export class CampaignFixtures {
|
|||
depth: 0,
|
||||
})
|
||||
this.own('contact', contact)
|
||||
// Contact.phone is optional at the collection level (name-only imports), but
|
||||
// every fixture contact carries one — narrow so specs can rely on it.
|
||||
if (typeof contact.phone !== 'string') {
|
||||
// The fixture contract is "one primary phone": every fixture contact carries
|
||||
// one — narrow so specs can rely on it (C112 keeps the array under the hood).
|
||||
const primary = contact.phones?.[0]?.value
|
||||
if (typeof primary !== 'string') {
|
||||
throw new Error('Fixture contacts must always carry a phone.')
|
||||
}
|
||||
return { ...contact, phone: contact.phone }
|
||||
return { ...contact, phone: primary }
|
||||
}
|
||||
|
||||
/** Assign advisors to a seeded municipality (tracked for reset on cleanup). */
|
||||
|
|
@ -945,7 +949,7 @@ export class CampaignFixtures {
|
|||
for (const contact of contacts.docs) {
|
||||
if (
|
||||
this.owned.contact.has(contact.id) ||
|
||||
this.hasMarker(contact.name, contact.email, contact.phone)
|
||||
this.hasMarker(contact.name, contact.email, contact.phones?.[0]?.value ?? null)
|
||||
) {
|
||||
this.own('contact', contact)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ describe('campaign integration fixtures', () => {
|
|||
collection: 'contact',
|
||||
data: {
|
||||
name: fixtures.value('Contato criado pelo servidor E2E'),
|
||||
phone: fixtures.phone(),
|
||||
phones: [{ value: fixtures.phone() }],
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
},
|
||||
|
|
@ -286,14 +286,14 @@ describe('campaign integration fixtures', () => {
|
|||
const findOrCreateSentinelContact = async (name: string, phone: string) => {
|
||||
const existing = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: phone } },
|
||||
where: { 'phones.value': { equals: phone } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
})
|
||||
if (existing.docs[0]) return existing.docs[0]
|
||||
return payload.create({
|
||||
collection: 'contact',
|
||||
data: { name, phone, state: 'BA', city: 'Salvador' },
|
||||
data: { name, phones: [{ value: phone }], state: 'BA', city: 'Salvador' },
|
||||
depth: 0,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
458
tests/int/campaignMultiPhones.int.spec.ts
Normal file
458
tests/int/campaignMultiPhones.int.spec.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
user: null as CampaignUser | null,
|
||||
}))
|
||||
const requestHeadersState = vi.hoisted(() => ({
|
||||
value: new Headers({ origin: 'http://localhost:3212' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/campaignAuth', () => ({
|
||||
getCampaignUser: async () => authState.user,
|
||||
}))
|
||||
vi.mock('next/headers', () => ({
|
||||
headers: async () => requestHeadersState.value,
|
||||
}))
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
createLeadershipRecord,
|
||||
updateLeadershipContactRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
createSupporterRecord,
|
||||
updateSupporterContactRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/supporter'
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import {
|
||||
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
primaryPhoneOf,
|
||||
reorderWithPrimaryPhone,
|
||||
} from '@/lib/phone'
|
||||
import config from '@/payload.config'
|
||||
import { toSupporterDetailViewModel } from '@/utilities/supporter/supporterViewModels'
|
||||
import {
|
||||
ensureLeasedConsent,
|
||||
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('C112 — múltiplos telefones por pessoa', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('keeps order = priority through the pure helpers', () => {
|
||||
expect(primaryPhoneOf(null)).toBeNull()
|
||||
expect(primaryPhoneOf([])).toBeNull()
|
||||
expect(primaryPhoneOf([{ value: '71911110000' }, { value: '71922220000' }])).toBe('71911110000')
|
||||
|
||||
expect(reorderWithPrimaryPhone([], '71911110000')).toEqual(['71911110000'])
|
||||
expect(
|
||||
reorderWithPrimaryPhone([{ value: '71911110000' }, { value: '71922220000' }], '71922220000'),
|
||||
).toEqual(['71922220000', '71911110000'])
|
||||
expect(reorderWithPrimaryPhone([{ value: '71911110000' }], null)).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a ficha with N phones on leadership create and lists the primary', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const primary = campaignFixtures().phone()
|
||||
const secondary = campaignFixtures().phone()
|
||||
|
||||
const created = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Liderança Multi Telefones',
|
||||
phones: [primary, secondary],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(created.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phones?.map((entry) => entry.value)).toEqual([primary, secondary])
|
||||
|
||||
const summary = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { id: { equals: created.id } },
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const contactSummary = summary.docs[0]?.contact
|
||||
expect(
|
||||
typeof contactSummary === 'object' && contactSummary !== null
|
||||
? primaryPhoneOf(contactSummary.phones)
|
||||
: null,
|
||||
).toBe(primary)
|
||||
})
|
||||
|
||||
it('rejects the same number twice in one ficha (dedupe within the person)', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const phone = campaignFixtures().phone()
|
||||
|
||||
await expect(
|
||||
createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Liderança Repetida',
|
||||
phones: [phone, phone],
|
||||
supportStatus: 'engajado',
|
||||
}),
|
||||
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: 'Ficha Repetida',
|
||||
phones: [{ value: phone }, { value: phone }],
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
|
||||
|
||||
// The invalid-number refusal is unchanged.
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: 'Ficha Inválida',
|
||||
phones: [{ value: '1199999999' }],
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow(BRAZILIAN_PHONE_INVALID_MESSAGE)
|
||||
})
|
||||
|
||||
it('sets the primary phone preserving the rest through the inline edit', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const first = campaignFixtures().phone()
|
||||
const second = campaignFixtures().phone()
|
||||
|
||||
const created = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Liderança Troca Principal',
|
||||
phones: [first, second],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
const updated = await updateLeadershipContactRecord(payload, coordinator, {
|
||||
id: created.id,
|
||||
field: 'phone',
|
||||
phone: second,
|
||||
})
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(updated.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phones?.map((entry) => entry.value)).toEqual([second, first])
|
||||
|
||||
// Clearing the primary removes it — the rest shifts up.
|
||||
await updateLeadershipContactRecord(payload, coordinator, {
|
||||
id: created.id,
|
||||
field: 'phone',
|
||||
phone: null,
|
||||
})
|
||||
const afterClear = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(created.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(afterClear.phones?.map((entry) => entry.value)).toEqual([first])
|
||||
})
|
||||
|
||||
it('writes the full list through the phones branch (ficha editor)', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const created = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Liderança Editor',
|
||||
phones: [campaignFixtures().phone()],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
const kept = campaignFixtures().phone()
|
||||
const added = campaignFixtures().phone()
|
||||
|
||||
const updated = await updateLeadershipContactRecord(payload, coordinator, {
|
||||
id: created.id,
|
||||
field: 'phones',
|
||||
phones: [kept, added],
|
||||
})
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(updated.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phones?.map((entry) => entry.value)).toEqual([kept, added])
|
||||
})
|
||||
|
||||
it('supporter detail shows every number and the list still shows the primary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
|
||||
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
})
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const primary = fixtures.phone()
|
||||
const secondary = fixtures.phone()
|
||||
|
||||
const created = await createSupporterRecord(payload, coordinator, {
|
||||
name: fixtures.value('Apoiador Multi Telefones'),
|
||||
phones: [primary, secondary],
|
||||
municipality: municipality.id,
|
||||
city: 'Salvador',
|
||||
consentAccepted: true,
|
||||
})
|
||||
const supporter = await payload.findByID({
|
||||
collection: 'supporter',
|
||||
id: created.id,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const detail = toSupporterDetailViewModel(supporter)
|
||||
expect(detail.phones).toEqual([primary, secondary])
|
||||
expect(detail.phone).toBe(primary)
|
||||
|
||||
// The supporter ficha editor (phones branch) replaces the whole list.
|
||||
const third = fixtures.phone()
|
||||
const updated = await updateSupporterContactRecord(payload, coordinator, {
|
||||
id: created.id,
|
||||
field: 'phones',
|
||||
phones: [third, primary],
|
||||
})
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(updated.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phones?.map((entry) => entry.value)).toEqual([third, primary])
|
||||
})
|
||||
})
|
||||
|
||||
describe('C112 — busca e convite com números secundários', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('finds the supporter by a NON-primary number (payload and SQL search paths)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
|
||||
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
})
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const primary = fixtures.phone()
|
||||
const secondary = fixtures.phone()
|
||||
|
||||
const created = await createSupporterRecord(payload, coordinator, {
|
||||
name: fixtures.value('Apoiador Busca Secundária'),
|
||||
phones: [primary, secondary],
|
||||
municipality: municipality.id,
|
||||
city: 'Salvador',
|
||||
consentAccepted: true,
|
||||
})
|
||||
|
||||
// Payload where path: the list search terms normalize to the full number.
|
||||
const { toPayloadWhere } = await import('@/utilities/supporter/supporterListFilters')
|
||||
const where = toPayloadWhere({ q: secondary })
|
||||
const hits = await payload.find({
|
||||
collection: 'supporter',
|
||||
where,
|
||||
depth: 1,
|
||||
limit: 10,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(hits.docs.map((doc) => doc.id)).toContain(created.id)
|
||||
|
||||
// SQL aggregate path: the raw-conditions builder must match a secondary too.
|
||||
const { toAggregateSqlConditions } =
|
||||
await import('@/utilities/supporter/supporterListSqlFilters')
|
||||
const sql = await import('@payloadcms/db-postgres')
|
||||
const { conditions, needsContactJoin } = toAggregateSqlConditions({
|
||||
q: secondary,
|
||||
})
|
||||
expect(needsContactJoin).toBe(true)
|
||||
expect(conditions.length).toBeGreaterThan(0)
|
||||
const conditionSql = sql.sql.join(conditions, sql.sql` AND `)
|
||||
const result = await payload.db.drizzle.execute(
|
||||
sql.sql`SELECT "supporter"."id" FROM "supporter"
|
||||
JOIN "contact" ON "contact"."id" = "supporter"."contact_id"
|
||||
WHERE ${conditionSql}`,
|
||||
)
|
||||
const rows = Array.isArray(result)
|
||||
? result
|
||||
: ((result as { rows: unknown }).rows as Array<{ id: number }>)
|
||||
const ids = rows.map((row) => row.id)
|
||||
expect(ids).toContain(created.id)
|
||||
})
|
||||
|
||||
it('builds the invite WhatsApp link with the PRIMARY of a multi-phone ficha', async () => {
|
||||
const { buildCampaignInviteWhatsAppLink } = await import('@/utilities/campaignInvite')
|
||||
const primary = campaignFixtures().phone()
|
||||
const secondary = campaignFixtures().phone()
|
||||
const url = buildCampaignInviteWhatsAppLink({
|
||||
phone: primary,
|
||||
recipientName: 'Liderança',
|
||||
senderName: 'Coordenação',
|
||||
inviteUrl: 'https://pt.jorgesolla.com.br/campanha/convite/abc',
|
||||
kind: 'autopreenchimento',
|
||||
})
|
||||
expect(url).toContain(`wa.me/55${primary}`)
|
||||
expect(url).not.toContain(`wa.me/55${secondary}`)
|
||||
|
||||
// And the invitation creation gate uses the ficha's primary.
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const created = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: campaignFixtures().value('Liderança Convite Multi'),
|
||||
phones: [primary, secondary],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
const { createCampaignInvite } = await import('@/app/(campaign)/campanha/actions/invite')
|
||||
authState.user = coordinator
|
||||
const invite = await createCampaignInvite({
|
||||
leadership: created.id,
|
||||
kind: 'autopreenchimento',
|
||||
})
|
||||
expect(invite.whatsappUrl).toContain(`wa.me/55${primary}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('C112 — dobradinha e assessor com N telefones', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('writes the dobradinha ficha list through the phones branch', async () => {
|
||||
const { updateStateDeputyContactRecord } =
|
||||
await import('@/app/(campaign)/campanha/actions/stateDeputy')
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy({
|
||||
name: `${fixtures.value('Dobradinha')} Multi`,
|
||||
})
|
||||
const first = fixtures.phone()
|
||||
const second = fixtures.phone()
|
||||
|
||||
const updated = await updateStateDeputyContactRecord(payload, coordinator, {
|
||||
id: stateDeputy.id,
|
||||
field: 'phones',
|
||||
phones: [first, second],
|
||||
})
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(updated.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second])
|
||||
})
|
||||
|
||||
it('writes the advisor ficha list and rejects duplicates', async () => {
|
||||
const { updateAdvisorContactFicha } = await import('@/app/(campaign)/campanha/actions/advisor')
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
authState.user = coordinator
|
||||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value('Assessora Multi Telefones'),
|
||||
})
|
||||
const first = fixtures.phone()
|
||||
const second = fixtures.phone()
|
||||
|
||||
const contactID = relationId(
|
||||
await payload
|
||||
.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: advisor.id,
|
||||
depth: 0,
|
||||
select: { contact: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
.then((account) => account.contact),
|
||||
)
|
||||
expect(contactID).not.toBeNull()
|
||||
|
||||
await updateAdvisorContactFicha({ contactId: contactID!, phones: [first, second] })
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: contactID!,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second])
|
||||
|
||||
await expect(
|
||||
updateAdvisorContactFicha({ contactId: contactID!, phones: [first, first] }),
|
||||
).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('C112 — CSV import com telefone compartilhado como secundário', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('flags a CSV phone carried as SECONDARY by two fichas (preview = confirm fail-closed)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
|
||||
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
})
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const sharedSecondary = fixtures.phone()
|
||||
// Two fichas whose PRIMARY differs but which both carry the CSV number.
|
||||
await fixtures.createContact({
|
||||
phones: [{ value: fixtures.phone() }, { value: sharedSecondary }],
|
||||
})
|
||||
await fixtures.createContact({
|
||||
phones: [{ value: fixtures.phone() }, { value: sharedSecondary }],
|
||||
})
|
||||
|
||||
const { previewSupporterImportText } =
|
||||
await import('@/app/(campaign)/campanha/actions/supporterImport')
|
||||
const csv = `nome,telefone,municipio,intencao\nMaria Souza,${sharedSecondary},Salvador,certo`
|
||||
const preview = await previewSupporterImportText(payload, coordinator, csv)
|
||||
expect(preview.counts.ok).toBe(0)
|
||||
expect(preview.sampleRows.some((row) => row.status === 'telefone_compartilhado')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ const createEngagedPhoneAccessGraph = async (
|
|||
) => {
|
||||
const contact = await fixtures.createContact({
|
||||
name: fixtures.value('Contato telefone'),
|
||||
phone: fixtures.phone(),
|
||||
})
|
||||
return fixtures.createLeadership({
|
||||
contact,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,14 +25,14 @@ const contactPhoneHook = Contact.hooks?.beforeValidate?.[0]
|
|||
|
||||
type ContactPhoneHookArgs = Parameters<NonNullable<typeof contactPhoneHook>>[0]
|
||||
|
||||
const runContactPhoneHook = async (phone: string) => {
|
||||
const runContactPhoneHook = async (phones: unknown) => {
|
||||
if (typeof contactPhoneHook !== 'function') {
|
||||
throw new Error('Expected Contact.beforeValidate phone hook.')
|
||||
}
|
||||
|
||||
const data = { phone }
|
||||
const data: { phones?: unknown } = { phones }
|
||||
await contactPhoneHook(stub<ContactPhoneHookArgs>({ data }))
|
||||
return data.phone
|
||||
return data.phones
|
||||
}
|
||||
|
||||
describe('Contact phone normalization boundary', () => {
|
||||
|
|
@ -44,21 +44,35 @@ describe('Contact phone normalization boundary', () => {
|
|||
const parsed = leadershipCreateSchema.parse({
|
||||
municipalities: [1],
|
||||
name: 'Liderança normalizada',
|
||||
phone: '+55 (71) 99999-1234',
|
||||
phones: ['+55 (71) 99999-1234'],
|
||||
})
|
||||
|
||||
expect(parsed.phone).toBe('71999991234')
|
||||
expect(await runContactPhoneHook(parsed.phone)).toBe('71999991234')
|
||||
expect(parsed.phones).toEqual(['71999991234'])
|
||||
expect(await runContactPhoneHook([{ value: parsed.phones[0] }])).toEqual([
|
||||
{ value: '71999991234' },
|
||||
])
|
||||
expect(phoneMocks.normalizeBrazilianPhone).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('normalizes a formatted phone sent directly to the Contact API boundary', async () => {
|
||||
expect(await runContactPhoneHook('+55 (71) 99999-1234')).toBe('71999991234')
|
||||
expect(await runContactPhoneHook([{ value: '+55 (71) 99999-1234' }])).toEqual([
|
||||
{ value: '71999991234' },
|
||||
])
|
||||
expect(phoneMocks.normalizeBrazilianPhone).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects an invalid canonical-length direct API phone without re-normalizing it', async () => {
|
||||
await expect(runContactPhoneHook('00000000000')).rejects.toThrow('Celular brasileiro inválido.')
|
||||
await expect(runContactPhoneHook([{ value: '00000000000' }])).rejects.toThrow(
|
||||
'Celular brasileiro inválido.',
|
||||
)
|
||||
expect(phoneMocks.normalizeBrazilianPhone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops empty entries and rejects a duplicate within the same ficha', async () => {
|
||||
expect(await runContactPhoneHook([{ value: '' }, { value: null }])).toEqual([])
|
||||
|
||||
await expect(
|
||||
runContactPhoneHook([{ value: '71999991234' }, { value: '71999991234' }]),
|
||||
).rejects.toThrow('Telefone repetido na ficha.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue