Merge pull request #640 from fsolla/C111-telefone-nao-unico-duas-pessoas-podem-compartilhar-o-me

C111 — Telefone não único: duas pessoas podem compartilhar o mesmo celular
This commit is contained in:
Francisco Solla 2026-08-11 03:58:55 +00:00 committed by GitHub
commit 170ce59ef4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 445 additions and 364 deletions

View file

@ -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:<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<phone,id>` 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<id>` 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)

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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 (

View file

@ -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,

View file

@ -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',

View file

@ -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

View file

@ -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'

View file

@ -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<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
}
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' }
}
}
}

View file

@ -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.

View file

@ -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<CampaignUser
data: contactData,
depth: 0,
// Intentional admin bypass: the account operation is already authorized;
// the ficha phone uniqueness is enforced by the Contact's own hook.
// the ficha write is scoped by the account link and may share the phone
// with other fichas (C111).
overrideAccess: true,
req,
})
@ -285,6 +287,28 @@ const deleteCampaignUserNotifications: CollectionBeforeDeleteHook = async ({ id,
})
}
/**
* Staged CSV-import batches authored by the deleted account must go BEFORE the
* account: `supporter_import_batch.actor_id` is NOT NULL with an `ON DELETE
* set null` FK (C6 schema drift — the column and the FK action disagree), so
* without this the delete fails with a not-null violation whenever the account
* left an unconsumed batch (10-minute window, no sweep). Same pattern as the
* passkey/notification cascade above; `deletePersonRecord` spells the same
* cleanup by hand.
*/
const deleteCampaignUserImportBatches: CollectionBeforeDeleteHook = async ({ id, req }) => {
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: [

View file

@ -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: [
{

View file

@ -38,6 +38,7 @@ import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
const importStatusLabels: Record<SupporterImportPreviewRow['status'], string> = {
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',

View file

@ -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 } },

View file

@ -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,
})

View file

@ -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<Payload, 'db' | 'find' | 'findByID'>
type ContactPhoneRequest = PayloadTransactionRequest | PayloadRequest
export const contactPhoneLockKeys = (phones: string[]): string[] =>
[...new Set(phones)].sort().map((phone) => `contact-phone:${phone}`)
export const acquireContactPhoneLocks = (
payload: Pick<Payload, 'db'>,
req: ContactPhoneRequest,
phones: string[],
): Promise<void> => acquireTextAdvisoryLocks(payload, req, contactPhoneLockKeys(phones))
export const assertContactPhoneAvailable = async (
payload: ContactPhonePayload,
req: ContactPhoneRequest,
phone: string,
contactID?: number,
conflictMessage = CONTACT_PHONE_CONFLICT_MESSAGE,
): Promise<void> => {
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<void> => {
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)
}
}

View file

@ -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<Payload, 'db'>
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<void> => acquireTextAdvisoryLocks(payload, req, contactPhoneLockKeys(phones))

View file

@ -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,

View file

@ -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'

View file

@ -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<string, unknown>): 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)
}
}

View file

@ -44,6 +44,7 @@ type CampaignCollection =
| 'campaignUser'
| 'consent'
| 'calendarFeed'
| 'supporterImportBatch'
type OwnedIDs = Record<CampaignCollection, Set<number>>
@ -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)

View file

@ -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 () => {

View file

@ -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)
},
)

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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()

View file

@ -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 () => {

View file

@ -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')
})

View file

@ -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',