diff --git a/docs/plans/telefone-nao-unico-impl.md b/docs/plans/telefone-nao-unico-impl.md new file mode 100644 index 00000000..cb52c7cc --- /dev/null +++ b/docs/plans/telefone-nao-unico-impl.md @@ -0,0 +1,84 @@ +# Impl: Telefone não único — duas pessoas podem compartilhar o mesmo celular + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #625 +Intenção: docs/plans/telefone-nao-unico.md +Appetite restante: herdado (~0,5–1 dia eng) — a mudança é de comportamento de escrita, sem schema e sem UI nova. + +## Leitura da intenção + +- **Outcome:** o telefone deixa de ser único em enforcement; qualquer fluxo de escrita de pessoa (liderança, apoiador, dobradinha, assessor/conta, pessoa) salva com número já usado por outra ficha; fluxos automáticos (import CSV, convite/login por telefone) nunca "chutam" qual pessoa — falham fechado com mensagem clara ou usam a chave não ambígua (a conta/username). +- **O que NÃO negociar:** os 5 fluxos salvam sem erro; import/convite não casam por chute em ambiguidade; busca por telefone continua achando todas as fichas; nenhum outro campo de `Contact` muda; mensagens de conflito ("Já existe outro contato com este celular.") somem dos fluxos de gestão; nada de reconciliação de fichas (anti-goal). +- **O que reavaliar:** + - O inventário provou que a unicidade é **enforcement-only** (hook `enforceUniqueContactPhone` + `assertContactPhone*`; `contact_phone_idx` é índice comum, não UNIQUE — migration `20260718_010733_consolidate_campaign_schema.ts:407`). Consequência: **SEM MIGRATION**, sem schema. + - A hipótese da intenção ("falha fechada onde o número é ambíguo") vale para o **import**; para a **criação de pessoa**, 2+ fichas com o mesmo phone deixa de ser estado excepcional (vira o normal do produto) → o find-or-create **cria ficha nova** em vez de lançar AMBIGUOUS — a identidade da pessoa em criação é a ficha recém-criada (não é chute; o staff digitou o nome). + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph Morre[Unicidade morre] + Hook[enforceUniqueContactPhone / skipContactPhoneInvariant] --> Removido + AssertW[assertContactPhoneWritable] --> Removido + AssertA[assertContactPhoneAvailable] --> Removido + end + subgraph Fica[O que fica] + Locks[contactPhoneLockKeys / acquireContactPhoneLocks] --> Renomeado[contactPhoneLocks.ts] + FOC[findOrCreateContactByPhone] --> 0cria[0 fichas → cria] + FOC --> 1reusa[1 ficha → reusa — matching onde existe] + FOC --> 2cria[2+ fichas → cria ficha nova — identidade é a ficha nova] + Import[preview + confirm import CSV] --> Amb[phone com 2+ fichas → sinaliza telefone_compartilhado; confirm re-checa fail-closed com locks] + Invite[redeemCampaignInvite] --> Username[check de contas por username permanece — login é de conta] + end +``` + +**Opções consideradas:** A (remover enforcement, find-or-create cria ficha nova em 2+) | B (remover enforcement, manter AMBIGUOUS no find-or-create) | C (manter unicidade e só "avisar" no cliente). +**Recomendação:** **A** — satisfaz o aceite literal ("salva sem erro em qualquer um dos 5 fluxos"): com phone compartilhado virando estado normal, o caso 2+ no find-or-create precisa salvar, e reusar uma das 2+ fichas seria o chute que a intenção proíbe; a ficha nova é a identidade da pessoa que o staff acabou de digitar. No import, o casamento por phone (1 ficha) permanece e a ambiguidade sinaliza (recomendação A da própria intenção). +**Rejeitadas:** B — 2+ fichas com o mesmo phone é o caso-núcleo do C111 (duas pessoas compartilhando); manter AMBIGUOUS bloquearia o cadastro da terceira pessoa e reproduziria o problema que a entrega resolve. C — mascarar o erro no cliente mantém a base inconsistente com o produto (o estado compartilhado é desejado) e não desbloqueia os fluxos server-side. + +### Componentes / mudanças + +- **`src/utilities/contactPhoneInvariant.ts` → `src/utilities/contactPhoneLocks.ts`** (rename, "edit the owner"): ficam `contactPhoneLockKeys` + `acquireContactPhoneLocks` (+ import de `POSTGRES_DEDUP_LOCK_MESSAGE`); saem `assertContactPhoneAvailable`, `assertContactPhoneWritable`, `CONTACT_PHONE_CONFLICT_MESSAGE` (sem usos restantes) e `CONTACT_PHONE_AMBIGUOUS_MESSAGE` (o find-or-create não lança mais; o import ganha mensagem própria). O lock `contact-phone:` passa a significar **serialização de leitura-escrita por phone** (find-or-create, import, tombstone, convite) — não mais unicidade. Rename é mecânico: ~5 imports de runtime + testes + allowlist `codebaseConventions.unit.spec.ts:434`. +- **`src/collections/Contact.ts`**: remove o hook `enforceUniqueContactPhone` do `beforeChange` (fica `enforceStateDeputyName`). `beforeValidate` de phone intocado. +- **`src/utilities/contactIdentity.ts`** (`findOrCreateContactByPhone`): 0 fichas → cria (mantém); 1 → reusa (mantém, dedupe C6/C99); **2+ → cria ficha nova** com o phone (a ficha nova é a identidade da pessoa em criação; o phone compartilhado é permitido). Remove o `context.skipContactPhoneInvariant` (o contrato morre junto do hook; o create via Local API roda sem hook de unicidade). Locks permanecem. +- **`src/app/(campaign)/campanha/actions/leadership.ts`** (`:266`, `:335`) e **`stateDeputy.ts`** (`:187`): removem `assertContactPhoneWritable` — o update de phone simplesmente passa pelo `payload.update` da ficha (sem hook de unicidade) e compartilha. +- **`src/utilities/campaignInviteRedemption.ts`** (`:132`, `:210`): removem `assertContactPhoneAvailable` (autofill e login) — a ficha do convite é conhecida (via `leadership.contact`); o número digitado pode coincidir com outra ficha. **Permanece** o check de contas por `username` (`:217-232`, `limit: 2` + INVALID se o username é de outra conta) — `campaignUser.username` é UNIQUE no banco (DB-level, intocado) e é a chave do login: quem não tem número exclusivo usa e-mail (recomendação A da intenção). +- **`src/collections/CampaignUser.ts`** (`ensureCampaignUserContactIdentity`): **sem mudança de código** — o sync conta→ficha agora passa porque o hook da ficha não rejeita mais; o phone compartilhado é o estado desejado. +- **Import CSV**: + - **`src/app/(campaign)/campanha/actions/supporterImport.ts`** — preview (`:224-264`): o `Map` para de sobrescrever silenciosamente; quando um phone do CSV resolve **2+ fichas**, todas as linhas com esse phone viram status novo `telefone_compartilhado` (resolução manual). Confirm (`:331-346`): após `acquireContactPhoneLocks`, **re-checa** os phones ok contra a base e aborta com mensagem clara se algum ficou ambíguo (a base pode ter mudado desde o preview). + - **`src/utilities/supporter/supporterImportBulk.ts`** (`:169-183`): o `SELECT … WHERE phone IN` passa a detectar phone com 2+ ids → **aborta** com a mesma mensagem (fail-closed defensivo; em produção o re-check do confirm já pega). + - **`src/utilities/supporter/supporterImport.ts`** (types): status `telefone_compartilhado`; `isPreviewErrorRow` segue falso para ele (entra em `counts.duplicate`, "resolução manual" como o duplicado). + - **`src/components/campaign/supporter/SupporterImportWizard.tsx`** (`:38-45`): rótulo novo `telefone_compartilhado: 'Telefone compartilhado (resolver)'`. Impeccable A — nenhuma superfície nova, só um status/label no wizard existente. +- **formActions safeMessages** (7 arquivos: `wizardLeadershipFormActions`, `assessores`, `liderancas`, `liderancas/nova`, `dobradinhas`, `apoiadores/novo`, `leaderSupporter.ts`): removem entradas órfãs de `CONTACT_PHONE_CONFLICT_MESSAGE`/`CONTACT_PHONE_AMBIGUOUS_MESSAGE` (nada mais lança essas mensagens). `apoiadores/novo` e `liderancas/nova` perdem o import inteiro da constante. +- **`src/utilities/people/personDelete.ts`** (`:149`) e **`supporter.ts`** (`:22,270`): **sem mudança** — tombstone `999` continua único por id e os locks permanecem (serialização). +- **Migration:** nenhuma (unicidade enforcement-only, confirmado no inventário). +- **Access / Consent:** nenhuma mudança — nada de novo escrito por Local API com ator não verificado; os `overrideAccess` existentes mantêm suas justificativas (o hook de unicidade que os exigia some). + +## Fases verificáveis + +1. **Tracer — morre a unicidade:** rename do módulo + remoção do hook/asserts/mensagens + find-or-create 2+ → cria; atualizar os testes que pinam CONFLICT/AMBIGUOUS (`campaignContactIdentity`, `campaignLeadership`, `campaignMunicipalityStateDeputyCreate`, `postgresTransactionLocks`, `campaignInviteArchitecture`, `codebaseConventions`). Gates: `pnpm gate:fast`. +2. **Import + convite:** status `telefone_compartilhado` no preview (com re-check fail-closed no confirm e no bulk); remoção dos asserts do convite mantendo o check de contas; novas coberturas int: liderança com phone de outra ficha (create/update) salva; apoiador com phone compartilhado; import com phone ambíguo na base sinaliza e confirm aborta; invite autofill com phone de outra ficha passa. +3. **Gates:** `pnpm gate:fast` bare; `pnpm build` local; review de diff de testes/rename. + +## Rabbit holes / Não escopo (engenharia) + +- **Tornar `username` não-único para "duas contas no mesmo número logarem"** — login é de conta (recomendação A da intenção); a 2ª pessoa usa e-mail. `username` UNIQUE é DB-level e fica. +- **Adicionar coluna/índice novo em `Contact`** — nada necessário; `phone` já tem índice. +- **Reconciliar as fichas duplicadas** que a mudança passa a permitir — anti-goal explícito; fica pós-eleição. +- **Migrar o dedupe do seed-minimal / scripts** (`seed-minimal.mjs` upsert por phone) — scripts operam em DBs controlados; sem mudança. +- **Mudar `CONTACT_PHONE_AMBIGUOUS_MESSAGE` de lugar para o import** — a mensagem nova do import é específica (fala de linha/CSV); a constante antiga morre junto do módulo. +- **Achado de execução — drift C6 `supporter_import_batch.actor_id` NOT NULL + FK `ON DELETE set null`:** deletar o `campaignUser` dono de um batch não consumido (janela de 10 min, sem sweep) falha com 23502 e aborta a transação (25P02 em cascata). O teste novo de import com batch órfão expôs. Corrigido em escopo: `CampaignUser.beforeDelete` ganha `deleteCampaignUserImportBatches` (mesmo padrão de passkeys/notifications; o `deletePersonRecord` já tratava manualmente) + o fixture cleanup descobre/deleta batches dos accounts owned. Sem migration (correção via hook; a coluna NOT NULL continua sendo a guarda de dados). + +## Riscos e mitigação + +- **Regressão do dedupe (reuso de 1 ficha):** o find-or-create mantém 0→cria / 1→reusa; apenas o caso 2+ muda (de falha para ficha nova). Testes de reuso existentes (`campaignLeadership.int.spec.ts:159-177`, `:285-310`) devem continuar passando — o único teste que muda nessa área é o de create direto de Contact duplicado (hook removido). +- **Import com base ambígua muda de "Map sobrescreve" (silencioso) para sinalizado/abort:** é o comportamento desejado (fail-closed), mas muda counts do preview em bases já duplicadas — coberto por teste novo; a mensagem é clara e a linha vai ao relatório/resolução manual. +- **Sync conta→ficha (C99) passa a compartilhar phone:** o teste `campaignContactIdentity.int.spec.ts:184-210` que pina o rollback do CONFLICT vira teste do novo comportamento (phone compartilhado sincroniza). A ficha do convite/account continua sendo a âncora — sem chute. +- **Lock de phone agora serializa pessoas distintas** (duas pessoas compartilhando o número): granularidade grossa, mas os locks só cobrem janelas de escrita curtas (find-or-create, import); sem impacto funcional. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto: 5 fluxos salvam com phone compartilhado; import/convite falham fechado (ou chave não ambígua); busca por phone intacta; zero mudança em outros campos de `Contact` +- [x] Invariantes AGENTS/engineering-standards: sem migration destrutiva; sem nova coleção; overrideAccess existentes com justificativa mantida; pt-BR nas mensagens; identificadores em inglês +- [x] Testes de domínio previstos (unit/int) onde write paths mudam: atualizados (`campaignContactIdentity`, `campaignLeadership`, `campaignMunicipalityStateDeputyCreate`, `postgresTransactionLocks`, `campaignInviteArchitecture`, `codebaseConventions`) + novos (compartilhamento em leadership/supporter/import/invite; batch órfão no delete de conta) diff --git a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts index 55d4682d..dea6c4d2 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts @@ -16,17 +16,11 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, -} from '@/utilities/contactPhoneInvariant' const safeMessages = [ LEADERSHIP_DUPLICATE_MESSAGE, LEADERSHIP_INVALID_CONTACT_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, ...leadershipStaffEditSafeMessages, ] as const diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 326236d4..777b905d 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -16,7 +16,6 @@ import { supporterCreateSchema, } from '@/lib/schemas/supporter' import { runCampaignFormAction } from '@/utilities/campaignFormActionError' -import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant' export type SupporterFormState = { status?: 'success' @@ -38,7 +37,6 @@ type SupporterFormValues = { const safeActionMessages = [ SUPPORTER_DUPLICATE_MESSAGE, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, SUPPORTER_STAFF_MESSAGE, SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE, SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE, diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts index 234b095d..bc59f427 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -18,21 +18,10 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, -} from '@/utilities/contactPhoneInvariant' const INVALID_FIELD_MESSAGE = 'Campo inválido.' -// The C99 ficha-sync fails the account edit with the ficha's own phone -// messages when the phone belongs to another ficha (or to two). -const advisorSafeMessages = [ - ...ADVISOR_ACTION_SAFE_MESSAGES, - INVALID_FIELD_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, -] as const +const advisorSafeMessages = [...ADVISOR_ACTION_SAFE_MESSAGES, INVALID_FIELD_MESSAGE] as const export const updateAdvisorProfileFormAction = async ( _state: CampaignFormActionState, diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index 0b3bd2b0..88e25e7a 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -30,10 +30,6 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, -} from '@/utilities/contactPhoneInvariant' const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const @@ -41,8 +37,6 @@ const stateDeputyContactSafeMessages = [ STATE_DEPUTY_STAFF_MESSAGE, STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_INVALID_CONTACT_MESSAGE, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, ] as const const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index 80ed2e0d..f1833ac1 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -24,10 +24,6 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, -} from '@/utilities/contactPhoneInvariant' const INVALID_FIELD_MESSAGE = 'Campo inválido.' @@ -38,8 +34,6 @@ const safeMessages = [ LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, INVALID_FIELD_MESSAGE, ] as const diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts index aaff42dd..bad50f50 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -12,13 +12,11 @@ import { runCampaignRedirectFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant' const safeMessages = [ LEADERSHIP_DUPLICATE_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, ...leadershipStaffEditSafeMessages, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, ] as const export const createLeadershipFormAction = async ( diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index c0f74ed4..d2da6385 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -18,7 +18,6 @@ import { requireSupporterRegistrationConsent } from '@/utilities/campaignConsent import { runCampaignFormAction } from '@/utilities/campaignFormActionError' import { LEADER_CONTACTS_HOME } from '@/utilities/campaignPageActor' import { findOrCreateContactByPhone } from '@/utilities/contactIdentity' -import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors' @@ -43,7 +42,6 @@ const MUNICIPALITY_OUT_OF_SCOPE_MESSAGE = const safeActionMessages = [ SUPPORTER_DUPLICATE_MESSAGE, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, LEADER_SUPPORTER_ONLY_MESSAGE, SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE, MUNICIPALITY_OUT_OF_SCOPE_MESSAGE, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index f8f56db1..be8b4f16 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -39,7 +39,6 @@ import { reloadUnrestrictedActor, } from '@/utilities/campaignActionContext' import { findOrCreateContactByPhone } from '@/utilities/contactIdentity' -import { assertContactPhoneWritable } from '@/utilities/contactPhoneInvariant' import { loadMunicipalityLeadershipSummaries } from '@/utilities/municipality/municipalityViewModels' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' @@ -263,7 +262,6 @@ export const updateLeadershipContactRecord = async ( contactData.email = data.email ?? null } else if (data.field === 'phone') { if (data.phone) { - await assertContactPhoneWritable(payload, req, contactID, data.phone) contactData.phone = data.phone } else { contactData.phone = null @@ -332,8 +330,6 @@ const updateLeadershipWizardRecord = async ( throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE) } - await assertContactPhoneWritable(payload, req, contactID, data.phone) - // bypass: contact write is staff-scoped via leadership access check above. await payload.update({ collection: 'contact', diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index 64f41ba8..9aca7413 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -36,7 +36,6 @@ import { runStaffEntityMutation, type StaffEntityPolicy, } from '@/utilities/campaignEntityActions' -import { assertContactPhoneWritable } from '@/utilities/contactPhoneInvariant' import { hookFilledCreateData } from '@/utilities/hookFilledData' import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { withPayloadTransaction } from '@/utilities/payloadTransaction' @@ -184,7 +183,6 @@ export const updateStateDeputyContactRecord = async ( contactData.email = data.email ?? null } else if (data.field === 'phone') { if (data.phone) { - await assertContactPhoneWritable(payload, req, contactID, data.phone) contactData.phone = data.phone } else { contactData.phone = null diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 4964fb25..0e2f62db 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -19,7 +19,7 @@ import { type ConsentDescriptor, } from '@/utilities/campaignConsent' import { findOrCreateContactByPhone } from '@/utilities/contactIdentity' -import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors' diff --git a/src/app/(campaign)/campanha/actions/supporterImport.ts b/src/app/(campaign)/campanha/actions/supporterImport.ts index 3112e78a..a8e5ca44 100644 --- a/src/app/(campaign)/campanha/actions/supporterImport.ts +++ b/src/app/(campaign)/campanha/actions/supporterImport.ts @@ -21,7 +21,7 @@ import { requireSupporterRegistrationConsent, requireSupporterVoteIntentionConsent, } from '@/utilities/campaignConsent' -import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks' @@ -226,39 +226,56 @@ export const previewSupporterImportText = async ( collection: 'contact', where: { phone: { in: candidatePhones } }, depth: 0, - limit: candidatePhones.length, + // 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 }, overrideAccess: true, }) - const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const)) + // 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"). + const contactByPhone = new Map() + const sharedPhones = new Set() + for (const doc of contacts.docs) { + if (!doc.phone) continue + if (contactByPhone.has(doc.phone)) { + sharedPhones.add(doc.phone) + continue + } + contactByPhone.set(doc.phone, doc.id) + } const contactIDs = [...contactByPhone.values()] - if (contactIDs.length > 0) { - const existingSupporters = await payload.find({ - collection: 'supporter', - where: { - and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }], - }, - depth: 0, - limit: contactIDs.length, - pagination: false, - select: { contact: true }, - overrideAccess: true, - }) - const supporterContactIDs = new Set( - existingSupporters.docs - .map((doc) => relationshipId(doc.contact)) - .filter((id): id is number => id !== null), - ) + const existingSupporters = await payload.find({ + collection: 'supporter', + where: { + and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }], + }, + depth: 0, + limit: contactIDs.length, + pagination: false, + select: { contact: true }, + overrideAccess: true, + }) + const supporterContactIDs = new Set( + existingSupporters.docs + .map((doc) => relationshipId(doc.contact)) + .filter((id): id is number => id !== null), + ) - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index] - if (!row || !isSupporterImportOkRow(row)) continue - const contactID = contactByPhone.get(row.normalizedPhone) - if (contactID && supporterContactIDs.has(contactID)) { - rows[index] = { ...row, status: 'duplicado_pelo_telefone' } - } + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index] + if (!row || !isSupporterImportOkRow(row)) continue + if (sharedPhones.has(row.normalizedPhone)) { + rows[index] = { ...row, status: 'telefone_compartilhado' } + continue + } + const contactID = contactByPhone.get(row.normalizedPhone) + if (contactID && supporterContactIDs.has(contactID)) { + rows[index] = { ...row, status: 'duplicado_pelo_telefone' } } } } diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts index b4a5e914..7bd523a3 100644 --- a/src/collections/CampaignDemand.ts +++ b/src/collections/CampaignDemand.ts @@ -65,7 +65,7 @@ const MAX_DEMAND_SLUG_ATTEMPTS = 20 * Opt-in via `req.context.campaignDemandUniqueSlug` (set by the form-created * record action): direct creates — the activity demand drafts (C90) and the * admin UI — keep the strict constraint, which the activity rollback contract - * pins. Fail closed like `skipContactPhoneInvariant`: only honored inside an + * pins. Fail closed like the C6 bulk-import opt-outs: only honored inside an * active transaction. A text advisory lock on the base slug serializes * concurrent creates of the same derived title, so the probe cannot race the * insert. diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index 0740ff0c..d64a2844 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -159,8 +159,9 @@ const resolveContactForAccount = async ({ * only collect account fields). An explicit `data.contact` (different from the * linked ficha) always wins — the hook only fills the gap. On updates of a * linked account, identity changes (name/e-mail/phone) are synced one-way - * account → ficha so the C100 list reads fresh data; a phone conflict fails - * the operation fail-closed with the ficha's standard message. + * account → ficha so the C100 list reads fresh data; a phone already used by + * another ficha is shared, not rejected (C111 — the phone is a contact + * channel; the account's `username` keeps the login key unique). * * Runs AFTER `preventSelfServicePrivilegedFields` on purpose: the strip * deletes the merged identity fields (including `contact`) for self-service, @@ -233,7 +234,8 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook { + await req.payload.delete({ + collection: 'supporterImportBatch', + where: { actor: { equals: id } }, + depth: 0, + // Intentional bypass: the account deletion itself is the authorization; + // the batches are transient staging rows owned by the deleted account + // (same cascade contract as the passkey/notification deletes above). + overrideAccess: true, + req, + }) +} + export const CampaignUser: CollectionConfig = { slug: 'campaignUser', labels: { @@ -342,7 +366,11 @@ export const CampaignUser: CollectionConfig = { preventSelfServicePrivilegedFields, ensureCampaignUserContactIdentity, ], - beforeDelete: [deleteCampaignUserPasskeys, deleteCampaignUserNotifications], + beforeDelete: [ + deleteCampaignUserPasskeys, + deleteCampaignUserNotifications, + deleteCampaignUserImportBatches, + ], afterRead: [removePrivateAuthFields], }, fields: [ diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index fd61a1c9..180de13d 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -2,54 +2,10 @@ import { CitiesByState } from '@/lib/cities' import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone' import { trimmedText } from '@/lib/text' import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess' -import { - acquireContactPhoneLocks, - assertContactPhoneAvailable, -} from '@/utilities/contactPhoneInvariant' import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant' import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload' import { APIError } from 'payload' -const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({ - data, - operation, - originalDoc, - req, -}) => { - // Opt-out for callers that have already acquired the phone advisory locks in the - // SAME Payload transaction (e.g. bulk import). Fail closed: the flag is only - // honored inside an active transaction — without one the xact-level advisory - // lock cannot be held, so we run the full check regardless of the caller's claim. - if (req.context?.skipContactPhoneInvariant === true) { - if (req.transactionID == null) { - throw new APIError( - 'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.', - 500, - ) - } - return data - } - - const phone = String(data.phone ?? originalDoc?.phone ?? '') - // Phone-less contacts (e.g. name-only leadership imports) have no uniqueness to enforce. - if (!phone) return data - const oldPhone = - operation === 'update' && typeof originalDoc?.phone === 'string' ? originalDoc.phone : undefined - - await acquireContactPhoneLocks( - req.payload, - req, - oldPhone === undefined ? [phone] : [oldPhone, phone], - ) - await assertContactPhoneAvailable( - req.payload, - req, - phone, - operation === 'update' ? Number(originalDoc?.id) : undefined, - ) - return data -} - const enforceStateDeputyName: CollectionBeforeChangeHook = async ({ data, operation, @@ -125,7 +81,7 @@ export const Contact: CollectionConfig = { return data }, ], - beforeChange: [enforceUniqueContactPhone, enforceStateDeputyName], + beforeChange: [enforceStateDeputyName], }, fields: [ { diff --git a/src/components/campaign/supporter/SupporterImportWizard.tsx b/src/components/campaign/supporter/SupporterImportWizard.tsx index 24d1ae21..35d2c09d 100644 --- a/src/components/campaign/supporter/SupporterImportWizard.tsx +++ b/src/components/campaign/supporter/SupporterImportWizard.tsx @@ -38,6 +38,7 @@ import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi' const importStatusLabels: Record = { ok: 'Pronto', duplicado_pelo_telefone: 'Duplicado (telefone)', + telefone_compartilhado: 'Telefone compartilhado (resolver)', telefone_invalido: 'Telefone inválido', municipio_nao_reconhecido: 'Município não reconhecido', nome_invalido: 'Nome inválido', diff --git a/src/utilities/campaignInviteRedemption.ts b/src/utilities/campaignInviteRedemption.ts index 7b032539..c67312c6 100644 --- a/src/utilities/campaignInviteRedemption.ts +++ b/src/utilities/campaignInviteRedemption.ts @@ -19,10 +19,7 @@ import { INVALID_CAMPAIGN_INVITE_MESSAGE, requireCampaignInvitePostgres, } from '@/utilities/campaignInviteRepository' -import { - assertContactPhoneAvailable, - contactPhoneLockKeys, -} from '@/utilities/contactPhoneInvariant' +import { contactPhoneLockKeys } from '@/utilities/contactPhoneLocks' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' @@ -129,13 +126,9 @@ export const redeemCampaignInviteAutofillRecord = async ( [originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)), ), ) - await assertContactPhoneAvailable( - payload, - req, - data.phone, - contactID, - INVALID_CAMPAIGN_INVITE_MESSAGE, - ) + // C111 — the phone is a contact channel, not a unique person identity: + // the ficha this invite anchors is known (leadership.contact), so the + // typed number may legitimately match another ficha's. await payload.update({ collection: 'contact', id: contactID, @@ -207,13 +200,10 @@ export const redeemCampaignInviteLoginRecord = async ( `account-username:${data.phone}`, ...(account ? [`invite-redemption-user:${account.id}`] : []), ]) - await assertContactPhoneAvailable( - payload, - req, - data.phone, - originalContact.id, - INVALID_CAMPAIGN_INVITE_MESSAGE, - ) + // C111 — the ficha this invite anchors is known; the typed phone may be + // shared with another ficha. The account side stays fail-closed below: + // `username` is the login key and remains DB-unique, so the second + // person with the same number uses e-mail (product recommendation A). const accounts = await payload.find({ collection: 'campaignUser', where: { username: { equals: data.phone } }, diff --git a/src/utilities/contactIdentity.ts b/src/utilities/contactIdentity.ts index afcdaf10..9cafaa03 100644 --- a/src/utilities/contactIdentity.ts +++ b/src/utilities/contactIdentity.ts @@ -3,10 +3,7 @@ import 'server-only' import type { Payload, PayloadRequest } from 'payload' import type { Contact } from '@/payload-types' -import { - acquireContactPhoneLocks, - CONTACT_PHONE_AMBIGUOUS_MESSAGE, -} from '@/utilities/contactPhoneInvariant' +import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest @@ -14,11 +11,15 @@ 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 (ambiguous phone fails - * closed) — the same critical section the supporter flows used to build - * around `upsertContactByPhone`. Without a phone, a fresh name-only ficha is - * created. Every "cria ou vincula a ficha" path shares one owner for the - * "BA default + ambiguous-phone fail-closed" policy. + * 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. * * Callers must already be inside an active Payload transaction (the advisory * lock requires it) and authorized to own the write (staff creation flows run @@ -51,19 +52,14 @@ export const findOrCreateContactByPhone = async ({ depth: 0, limit: 2, pagination: false, - // Intentional admin bypass: the phone invariant must see every ficha, + // Intentional admin bypass: the phone match must see every ficha, // including ones outside the actor's campaign scope. overrideAccess: true, req, }) - if (contacts.totalDocs > 1) { - throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE) - } - - const existing = contacts.docs[0] - if (existing) { - return { contactID: existing.id, reused: true } + if (contacts.totalDocs === 1) { + return { contactID: contacts.docs[0]!.id, reused: true } } } @@ -79,12 +75,6 @@ export const findOrCreateContactByPhone = async ({ }, depth: 0, overrideAccess: true, - // With a phone, the advisory lock is held for this transaction and the - // find above already proved the phone is free — the Contact - // phone-invariant hook can skip its redundant lock+availability check - // (same contract as the C6 bulk-import path, fail-closed on a missing - // transaction). Without a phone there is no uniqueness to enforce. - ...(phone ? { context: { skipContactPhoneInvariant: true as const } } : {}), req, }) diff --git a/src/utilities/contactPhoneInvariant.ts b/src/utilities/contactPhoneInvariant.ts deleted file mode 100644 index 0f8fbef9..00000000 --- a/src/utilities/contactPhoneInvariant.ts +++ /dev/null @@ -1,114 +0,0 @@ -import 'server-only' - -import type { Payload, PayloadRequest } from 'payload' - -import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' -import { - acquireTextAdvisoryLocks, - POSTGRES_DEDUP_LOCK_MESSAGE, -} from '@/utilities/postgresTransactionLocks' - -export const CONTACT_PHONE_CONFLICT_MESSAGE = 'Já existe outro contato com este celular.' - -/** - * Several contacts share the phone — a data-quality problem only an admin can - * resolve. Thrown by the upsert paths and matched verbatim by `safeMessages`. - */ -export const CONTACT_PHONE_AMBIGUOUS_MESSAGE = - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.' - -type ContactPhonePayload = Pick -type ContactPhoneRequest = PayloadTransactionRequest | PayloadRequest - -export const contactPhoneLockKeys = (phones: string[]): string[] => - [...new Set(phones)].sort().map((phone) => `contact-phone:${phone}`) - -export const acquireContactPhoneLocks = ( - payload: Pick, - req: ContactPhoneRequest, - phones: string[], -): Promise => acquireTextAdvisoryLocks(payload, req, contactPhoneLockKeys(phones)) - -export const assertContactPhoneAvailable = async ( - payload: ContactPhonePayload, - req: ContactPhoneRequest, - phone: string, - contactID?: number, - conflictMessage = CONTACT_PHONE_CONFLICT_MESSAGE, -): Promise => { - const conflicts = await payload.find({ - collection: 'contact', - where: { - and: [ - { phone: { equals: phone } }, - ...(contactID === undefined ? [] : [{ id: { not_equals: contactID } }]), - ], - }, - depth: 0, - limit: 1, - pagination: false, - // Intentional admin bypass: the uniqueness invariant must see EVERY contact - // with the phone, including ones the actor cannot read — a scoped check - // would let duplicates slip in through the invisible half of the table. - overrideAccess: true, - req, - }) - if (conflicts.totalDocs > 0) throw new Error(conflictMessage) -} - -/** - * Checks the phone invariant for an already-authorized Contact update. The - * caller must prove access to the owning campaign join before using the - * intentional admin-bypass reads/writes here. - */ -export const assertContactPhoneWritable = async ( - payload: ContactPhonePayload, - req: ContactPhoneRequest, - contactID: number, - phone: string, -): Promise => { - if (payload.db.name !== 'postgres') { - throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) - } - - const currentContact = await payload.findByID({ - collection: 'contact', - id: contactID, - depth: 0, - select: { phone: true }, - // Intentional admin bypass: the caller has already proved ownership through - // the campaign join; this read only obtains the old value for lock ordering. - overrideAccess: true, - req, - }) - const oldPhone = currentContact.phone ?? undefined - - // Lock both sides before checking availability. This prevents concurrent - // swaps (A -> B and B -> A) from waiting on each other's hook locks. - await acquireContactPhoneLocks( - payload, - req, - [oldPhone, phone].filter((value): value is string => Boolean(value)), - ) - - const contactsWithPhone = await payload.find({ - collection: 'contact', - where: { phone: { equals: phone } }, - depth: 0, - limit: 2, - pagination: false, - // Intentional admin bypass: phone uniqueness must see every Contact, - // including one outside the actor's campaign scope. - overrideAccess: true, - req, - }) - - if (contactsWithPhone.totalDocs > 1) { - throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE) - } - - const phoneOwner = contactsWithPhone.docs[0] - if (phoneOwner && phoneOwner.id !== contactID) { - await assertContactPhoneAvailable(payload, req, phone, contactID) - } -} diff --git a/src/utilities/contactPhoneLocks.ts b/src/utilities/contactPhoneLocks.ts new file mode 100644 index 00000000..85789279 --- /dev/null +++ b/src/utilities/contactPhoneLocks.ts @@ -0,0 +1,25 @@ +import 'server-only' + +import type { Payload, PayloadRequest } from 'payload' + +import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' + +type ContactPhonePayload = Pick +type ContactPhoneRequest = PayloadTransactionRequest | PayloadRequest + +/** + * Advisory locks keyed by phone (C111 — the phone is a contact channel, not a + * unique person identity, so these serialize read-modify-write flows that + * look contacts up by phone: find-or-create, CSV import, invite redemption, + * LGPD tombstone). They no longer guard uniqueness — they prevent two + * concurrent flows from racing the same phone key. + */ +export const contactPhoneLockKeys = (phones: string[]): string[] => + [...new Set(phones)].sort().map((phone) => `contact-phone:${phone}`) + +export const acquireContactPhoneLocks = ( + payload: ContactPhonePayload, + req: ContactPhoneRequest, + phones: string[], +): Promise => acquireTextAdvisoryLocks(payload, req, contactPhoneLockKeys(phones)) diff --git a/src/utilities/people/personDelete.ts b/src/utilities/people/personDelete.ts index 8faac88b..d59b703f 100644 --- a/src/utilities/people/personDelete.ts +++ b/src/utilities/people/personDelete.ts @@ -9,7 +9,7 @@ import { } from '@/lib/schemas/personDelete' import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types' import { isCampaignUnrestricted } from '@/utilities/campaignAccess' -import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks' import { withPayloadTransaction, type PayloadTransactionRequest, diff --git a/src/utilities/supporter/supporterImport.ts b/src/utilities/supporter/supporterImport.ts index 4c3bd2ba..12c92188 100644 --- a/src/utilities/supporter/supporterImport.ts +++ b/src/utilities/supporter/supporterImport.ts @@ -1,8 +1,16 @@ import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +/** + * C111 — a CSV phone that already belongs to two or more fichas cannot be + * matched to a person; the row is flagged for manual resolution instead. + */ +export const SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE = + 'Este celular pertence a mais de um contato na base. Remova a linha do arquivo ou resolva a duplicidade antes de continuar.' + type SupporterImportRowStatus = | 'ok' | 'duplicado_pelo_telefone' + | 'telefone_compartilhado' | 'telefone_invalido' | 'municipio_nao_reconhecido' | 'nome_invalido' diff --git a/src/utilities/supporter/supporterImportBulk.ts b/src/utilities/supporter/supporterImportBulk.ts index afac5ec5..d5b3b322 100644 --- a/src/utilities/supporter/supporterImportBulk.ts +++ b/src/utilities/supporter/supporterImportBulk.ts @@ -15,6 +15,7 @@ import { } from '@/utilities/drizzleBulk' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks' +import { SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE } from '@/utilities/supporter/supporterImport' export type SupporterImportBulkRow = { telefone: string @@ -132,9 +133,10 @@ const asContactID = (row: Record): number | null => { * caller's Payload transaction. Phone advisory locks MUST already be held for * every phone in `rows` (the caller acquires them before invoking this helper). * - * Contacts are inserted via drizzle (bypassing the Contact phone-invariant hook, - * which is safe because the locks guarantee uniqueness within the txn). The - * supporter insert uses `ON CONFLICT DO NOTHING` on the unique + * Contacts are inserted via drizzle (bypassing the Contact collection hooks, + * which is safe because the phone locks serialize this flow and the shared- + * phone abort below proves each phone still resolves to at most one ficha). + * The supporter insert uses `ON CONFLICT DO NOTHING` on the unique * `(contact_id, municipality_id)` index as a last-resort guard against races. * * All reads (existing contacts, existing supporters) and the ID recovery for @@ -178,7 +180,15 @@ export const bulkInsertSupporterImport = async (args: { ) for (const row of existingContactRows) { const parsed = asPhoneKeyedRow(row) - if (parsed) contactIdByPhone.set(parsed.phone, parsed.id) + if (!parsed) continue + // C111 — the phone is not unique. The preview flags shared phones, but + // the base can change between preview and confirm; with the phone + // advisory locks held, a second ficha here is a real ambiguity, so the + // import aborts fail-closed instead of silently picking a ficha. + if (contactIdByPhone.has(parsed.phone)) { + throw new Error(SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE) + } + contactIdByPhone.set(parsed.phone, parsed.id) } } diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 937fd0e2..81186c88 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -44,6 +44,7 @@ type CampaignCollection = | 'campaignUser' | 'consent' | 'calendarFeed' + | 'supporterImportBatch' type OwnedIDs = Record> @@ -128,6 +129,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({ campaignUser: new Set(), consent: new Set(), calendarFeed: new Set(), + supporterImportBatch: new Set(), }) /** @@ -938,6 +940,13 @@ export class CampaignFixtures { } if (userIDs.length > 0) { + const importBatches = await this.rootPayload.find({ + collection: 'supporterImportBatch', + where: { actor: { in: userIDs } }, + depth: 0, + pagination: false, + }) + for (const batch of importBatches.docs) this.own('supporterImportBatch', batch.id) const [updates, notifications, pushSubscriptions] = await Promise.all([ this.rootPayload.find({ collection: 'municipalityUpdate', @@ -1149,7 +1158,12 @@ export class CampaignFixtures { await this.deleteOwned(collection, req) } await this.deleteNotificationOwned(req) - for (const collection of ['campaignUser', 'consent', 'users'] as const) { + for (const collection of [ + 'supporterImportBatch', + 'campaignUser', + 'consent', + 'users', + ] as const) { await this.deleteOwned(collection, req) } await this.resetTouchedMunicipalities(req) diff --git a/tests/int/campaignContactIdentity.int.spec.ts b/tests/int/campaignContactIdentity.int.spec.ts index ac01f16a..3a7a61e1 100644 --- a/tests/int/campaignContactIdentity.int.spec.ts +++ b/tests/int/campaignContactIdentity.int.spec.ts @@ -6,10 +6,6 @@ import { getPayload } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' import config from '@/payload.config' -import { - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - CONTACT_PHONE_CONFLICT_MESSAGE, -} from '@/utilities/contactPhoneInvariant' import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures' @@ -181,24 +177,22 @@ describe('campaignUser → Contact identity (C99)', () => { expect(contact).toMatchObject({ name: 'Nome Atualizado', phone: nextPhone }) }) - it('fails the update fail-closed when the new phone belongs to another ficha', async () => { + it('shares the phone with another ficha when the account phone is updated (C111)', async () => { const fixtures = campaignFixtures() const actor = await fixtures.createCampaignUser('coordinator') const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() }) const other = await fixtures.createContact() - await expect( - payload.update({ - collection: 'campaignUser', - id: user.id, - data: { phone: other.phone }, - depth: 0, - user: actor, - overrideAccess: true, - }), - ).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE) + await payload.update({ + collection: 'campaignUser', + id: user.id, + data: { phone: other.phone }, + depth: 0, + user: actor, + overrideAccess: true, + }) - // The failed update must not have half-applied. + // The account's own ficha now carries the other ficha's phone; both remain. const account = await fichaOf(user.id) const contact = await payload.findByID({ collection: 'contact', @@ -206,7 +200,17 @@ describe('campaignUser → Contact identity (C99)', () => { depth: 0, overrideAccess: true, }) - expect(contact.phone).toBe(user.phone) + expect(contact.phone).toBe(other.phone) + + const othersWithPhone = await payload.find({ + collection: 'contact', + where: { phone: { equals: other.phone } }, + depth: 0, + limit: 2, + pagination: false, + overrideAccess: true, + }) + expect(othersWithPhone.totalDocs).toBe(2) }) it('links a previously unlinked account by phone on update', async () => { @@ -237,22 +241,41 @@ describe('campaignUser → Contact identity (C99)', () => { expect(relationId(updated.contact)).not.toBeNull() }) - it('fails closed on two fichas sharing the phone', async () => { + it('creates a fresh ficha when the phone already belongs to two fichas (C111)', async () => { const fixtures = campaignFixtures() const phone = fixtures.phone() - // The collection hook forbids duplicates, so the ambiguous state is planted - // directly (the same legacy-data shape `findOrCreateContactByPhone` guards). + // 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('Ambíguo A')}', '', '${phone}', 'BA', ''), - ('${fixtures.value('Ambíguo B')}', '', '${phone}', 'BA', '') + VALUES ('${fixtures.value('Compartilhado A')}', '', '${phone}', 'BA', ''), + ('${fixtures.value('Compartilhado B')}', '', '${phone}', 'BA', '') `), ) - await expect(fixtures.createCampaignUser('advisor', { phone })).rejects.toThrow( - CONTACT_PHONE_AMBIGUOUS_MESSAGE, - ) + const user = await fixtures.createCampaignUser('advisor', { phone }) + const account = await fichaOf(user.id) + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(account.contact) as number, + depth: 0, + overrideAccess: true, + }) + // 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) + + const allWithPhone = await payload.find({ + collection: 'contact', + where: { phone: { equals: phone } }, + depth: 0, + limit: 0, + pagination: false, + overrideAccess: true, + }) + expect(allWithPhone.totalDocs).toBe(3) }) it('lets the admin unlink explicitly without auto-relinking', async () => { diff --git a/tests/int/campaignInvite.int.spec.ts b/tests/int/campaignInvite.int.spec.ts index db61a976..1875d0dd 100644 --- a/tests/int/campaignInvite.int.spec.ts +++ b/tests/int/campaignInvite.int.spec.ts @@ -1038,7 +1038,7 @@ describe('campaign invite domain', () => { ) itWithInviteConsent( - 'rejects duplicate phone takeover, rolls back consumption, and permits a safe retry', + 'shares a phone with another ficha on autofill and keeps the invite consumed (C111)', async () => { const actions = await import('@/app/(campaign)/campanha/actions/invite') const coordinator = await campaignFixtures().createCampaignUser('coordinator') @@ -1050,6 +1050,8 @@ describe('campaign invite domain', () => { kind: 'autopreenchimento', }) + // C111 — the ficha the invite anchors is known; the typed phone may + // legitimately match another ficha's, and the write shares it. await expect( actions.redeemCampaignInviteAutofill({ token: invite.token, @@ -1057,23 +1059,19 @@ describe('campaign invite domain', () => { phone: owner.contact.phone, consentAccepted: true, }), - ).rejects.toThrow('Convite inválido ou expirado') + ).resolves.toEqual({ ok: true }) + await expect( payload.findByID({ collection: 'contact', id: target.contact.id, depth: 0 }), - ).resolves.toMatchObject({ phone: target.contact.phone }) + ).resolves.toMatchObject({ phone: owner.contact.phone }) await expect( - actions.redeemCampaignInviteAutofill({ - token: invite.token, - name: target.contact.name, - phone: target.contact.phone, - consentAccepted: true, - }), - ).resolves.toEqual({ ok: true }) + payload.findByID({ collection: 'contact', id: owner.contact.id, depth: 0 }), + ).resolves.toMatchObject({ phone: owner.contact.phone }) }, ) itWithInviteConsent( - 'serializes concurrent phone changes to one target and preserves the rejected invite', + 'lets two concurrent autofills share one phone and consumes both invites (C111)', async () => { const actions = await import('@/app/(campaign)/campanha/actions/invite') const coordinator = await campaignFixtures().createCampaignUser('coordinator') @@ -1108,27 +1106,20 @@ describe('campaign invite domain', () => { const results = await Promise.allSettled( submissions.map((submission) => actions.redeemCampaignInviteAutofill(submission)), ) - expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1) - expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1) + // C111 — the phone is a contact channel: both invites may share it. + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(2) const contacts = await Promise.all( [first.contact.id, second.contact.id].map((id) => payload.findByID({ collection: 'contact', id, depth: 0 }), ), ) - expect(contacts.filter(({ phone }) => phone === targetPhone)).toHaveLength(1) - const rejectedIndex = contacts.findIndex(({ phone }) => phone !== targetPhone) - await expect( - actions.redeemCampaignInviteAutofill({ - ...submissions[rejectedIndex]!, - phone: contacts[rejectedIndex]!.phone!, - }), - ).resolves.toEqual({ ok: true }) + expect(contacts.every(({ phone }) => phone === targetPhone)).toBe(true) }, ) itWithInviteConsent( - 'orders old and new phone locks so opposite invite swaps finish without deadlock', + 'completes opposite phone swaps without deadlock and shares the numbers (C111)', async () => { const actions = await import('@/app/(campaign)/campanha/actions/invite') const coordinator = await campaignFixtures().createCampaignUser('coordinator') @@ -1158,7 +1149,15 @@ describe('campaign invite domain', () => { consentAccepted: true, }), ]) - expect(results.every(({ status }) => status === 'rejected')).toBe(true) + // Both write the other's number (shared channel) — the ordered locks + // keep the swap deadlock-free instead of serializing exclusivity. + expect(results.every(({ status }) => status === 'fulfilled')).toBe(true) + const [firstContact, secondContact] = await Promise.all([ + 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) }, ) diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index a1609f81..5742d881 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -15,7 +15,6 @@ import { Leadership } from '@/collections/Leadership' import { contactSchema } from '@/lib/schemas/contact' import { leadershipCreateSchema, leadershipInternalUpdateSchema } from '@/lib/schemas/leadership' import config from '@/payload.config' -import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant' import { getTestTransactionBackendPID, waitForAdvisoryLockWaiter, @@ -282,7 +281,7 @@ describe('campaign leadership domain', () => { expect(leaderships.docs).toHaveLength(1) }) - it('rejects a duplicate Contact write before leadership dedupe can become ambiguous', async () => { + it('allows a second Contact with the same phone and creates a fresh ficha on leadership create (C111)', async () => { const coordinator = await campaignFixtures().createCampaignUser('coordinator') const municipality = await campaignFixtures().getMunicipality() const phone = campaignFixtures().phone() @@ -292,13 +291,15 @@ describe('campaign leadership domain', () => { data: { name: 'Primeiro Contato', phone, state: 'BA', city: 'Salvador' }, }) - await expect( - payload.create({ - collection: 'contact', - data: { name: 'Segundo Contato', phone, state: 'BA', city: 'Salvador' }, - }), - ).rejects.toThrow('Já existe outro contato com este celular') + // 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' }, + }) + // Two fichas already share the phone, so the leadership create cannot + // match a person — the identity of the person being created is a fresh + // ficha, never a guess among the existing ones. await expect( createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], @@ -306,7 +307,7 @@ describe('campaign leadership domain', () => { phone, supportStatus: 'engajado', }), - ).resolves.toMatchObject({ contactReused: true }) + ).resolves.toMatchObject({ contactReused: false }) }) it('enforces the unique contact relationship at the database', async () => { @@ -695,7 +696,7 @@ describe('campaign leadership domain', () => { expect(contact.phone).toBe(newPhone) }) - it('rejects leadership contact phone that belongs to another person', async () => { + it('shares a leadership contact phone with another person (C111)', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() @@ -708,13 +709,29 @@ describe('campaign leadership domain', () => { supportStatus: 'engajado', }) - await expect( - updateLeadershipContactRecord(payload, coordinator, { - id: leadership.id, - field: 'phone', - phone: takenPhone, - }), - ).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE) + const updated = await updateLeadershipContactRecord(payload, coordinator, { + id: leadership.id, + field: 'phone', + phone: takenPhone, + }) + + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(updated.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phone).toBe(takenPhone) + + const withPhone = await payload.find({ + collection: 'contact', + where: { phone: { equals: takenPhone } }, + depth: 0, + limit: 2, + pagination: false, + overrideAccess: true, + }) + expect(withPhone.totalDocs).toBe(2) }) it('denies leadership contact edits outside advisor municipality scope', async () => { diff --git a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts index d98fa885..0de78fbe 100644 --- a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts +++ b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts @@ -11,9 +11,8 @@ import { } from '@/app/(campaign)/campanha/actions/stateDeputy' import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy' import config from '@/payload.config' -import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant' -import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures' +import { installCampaignFixtures, relationId, relationIds } from '../helpers/campaignFixtures' let payload: Payload const campaignFixtures = installCampaignFixtures({ @@ -234,7 +233,7 @@ describe('StateDeputy contact fields (B163)', () => { }) }) - it('rejects a phone already owned by another Contact', async () => { + it('shares a phone with another Contact (C111)', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') const stateDeputy = await fixtures.createStateDeputy({ @@ -243,13 +242,29 @@ describe('StateDeputy contact fields (B163)', () => { const takenPhone = fixtures.phone() await fixtures.createContact({ phone: takenPhone }) - await expect( - updateStateDeputyContactRecord(payload, coordinator, { - id: stateDeputy.id, - field: 'phone', - phone: takenPhone, - }), - ).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE) + const updated = await updateStateDeputyContactRecord(payload, coordinator, { + id: stateDeputy.id, + field: 'phone', + phone: takenPhone, + }) + + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(updated.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phone).toBe(takenPhone) + + const withPhone = await payload.find({ + collection: 'contact', + where: { phone: { equals: takenPhone } }, + depth: 0, + limit: 2, + pagination: false, + overrideAccess: true, + }) + expect(withPhone.totalDocs).toBe(2) }) it('rejects renaming a dobradinha to another dobradinha name', async () => { diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts index 94070a8b..bc647a98 100644 --- a/tests/int/campaignSupporter.int.spec.ts +++ b/tests/int/campaignSupporter.int.spec.ts @@ -10,12 +10,19 @@ import { removeSupporterDataRecord, setSupporterVoteIntentionRecord, } from '@/app/(campaign)/campanha/actions/supporter' -import { supporterCreateSchema } from '@/lib/schemas/supporter' +import { + SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE, + supporterCreateSchema, +} from '@/lib/schemas/supporter' import config from '@/payload.config' import { SUPPORTER_REGISTRATION_CONSENT_KEY, SUPPORTER_VOTE_INTENTION_CONSENT_KEY, } from '@/utilities/campaignConsent' +import { + isPreviewErrorRow, + SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE, +} from '@/utilities/supporter/supporterImport' import { loadSupporterListOverviewData, loadSupportersPageData, @@ -300,6 +307,58 @@ describe('campaign supporter domain', () => { fixtures.own('supporter', created.docs[0]!.id) }) + it('flags a CSV phone shared by two fichas for manual resolution (C111)', async () => { + const fixtures = campaignFixtures() + 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 }) + + const csv = [ + 'nome,telefone,municipio,intencao', + `Maria Souza,${sharedPhone},Salvador,certo`, + ].join('\n') + + const preview = await previewSupporterImportText(payload, coordinator, csv) + const row = preview.sampleRows[0] + expect(row).toBeDefined() + expect(row.status).toBe('telefone_compartilhado') + expect(isPreviewErrorRow(row)).toBe(true) + + // The shared-phone row never reaches the staged ok set: confirming an + // all-flagged batch fails closed instead of importing a guessed ficha. + await expect( + confirmSupporterImportRecord(payload, coordinator, { + operatorAttested: true, + importToken: preview.importToken, + }), + ).rejects.toThrow(SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE) + }) + + it('aborts the confirm fail-closed when the base gains a second ficha for an ok phone (C111)', async () => { + const fixtures = campaignFixtures() + await ensureSupporterConsents() + const coordinator = await fixtures.createCampaignUser('coordinator') + const phone = fixtures.phone() + await fixtures.createContact({ phone }) + + const csv = `nome,telefone,municipio,intencao\nAna Silva,${phone},Salvador,certo` + const preview = await previewSupporterImportText(payload, coordinator, csv) + expect(preview.counts.ok).toBe(1) + + // 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 expect( + confirmSupporterImportRecord(payload, coordinator, { + operatorAttested: true, + importToken: preview.importToken, + }), + ).rejects.toThrow(SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE) + }) + it('rejects a tampered import token and a token issued to another actor', async () => { const fixtures = campaignFixtures() await ensureSupporterConsents() diff --git a/tests/int/postgresTransactionLocks.int.spec.ts b/tests/int/postgresTransactionLocks.int.spec.ts index 942cf67f..e2979451 100644 --- a/tests/int/postgresTransactionLocks.int.spec.ts +++ b/tests/int/postgresTransactionLocks.int.spec.ts @@ -4,10 +4,7 @@ import { getPayload, type Payload } from 'payload' import { beforeAll, describe, expect, it, vi } from 'vitest' import config from '@/payload.config' -import { - CONTACT_PHONE_CONFLICT_MESSAGE, - contactPhoneLockKeys, -} from '@/utilities/contactPhoneInvariant' +import { contactPhoneLockKeys } from '@/utilities/contactPhoneLocks' import { acquireTextAdvisoryLocks, getPostgresTransactionDatabase, @@ -67,7 +64,6 @@ describe('PostgreSQL transaction advisory locks', () => { 'contact-phone:71999990001', 'contact-phone:71999990002', ]) - expect(CONTACT_PHONE_CONFLICT_MESSAGE).toBe('Já existe outro contato com este celular.') }) it('uses only the exact Payload transaction session and fails closed without it', async () => { diff --git a/tests/unit/campaignInviteArchitecture.unit.spec.ts b/tests/unit/campaignInviteArchitecture.unit.spec.ts index 49ded905..af9bddea 100644 --- a/tests/unit/campaignInviteArchitecture.unit.spec.ts +++ b/tests/unit/campaignInviteArchitecture.unit.spec.ts @@ -40,7 +40,11 @@ describe('campaign invite module boundaries', () => { expect(creation).not.toContain('@payloadcms/db-postgres') expect(redemption).toContain('resolveInviteConsent') - expect(redemption).toContain('assertContactPhoneAvailable') + // C111 — the invite may write a phone shared with another ficha, but the + // account side stays fail-closed: the username is the login key (DB-unique) + // and the "more than one account" guard must survive. + expect(redemption).toContain('contactPhoneLockKeys') + expect(redemption).toContain('Existe mais de uma conta para este celular.') expect(redemption).toContain("role: 'leader'") expect(redemption).not.toContain('@payloadcms/db-postgres') }) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 488dfd67..34587e54 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -434,7 +434,7 @@ describe('src/utilities top-level is pinned', () => { 'campaignUserProfile.ts', 'consentContentHash.ts', 'contactIdentity.ts', - 'contactPhoneInvariant.ts', + 'contactPhoneLocks.ts', 'dashboardPriorityMunicipalities.ts', 'detailTabUi.ts', 'documentReads.ts',