mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
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:
commit
170ce59ef4
32 changed files with 445 additions and 364 deletions
84
docs/plans/telefone-nao-unico-impl.md
Normal file
84
docs/plans/telefone-nao-unico-impl.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
25
src/utilities/contactPhoneLocks.ts
Normal file
25
src/utilities/contactPhoneLocks.ts
Normal 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))
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue