teqo-backup/src/utilities/campaignInviteRedemption.ts
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -03:00

305 lines
11 KiB
TypeScript

import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf, reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import {
campaignInviteAutofillSchema,
campaignInviteLoginSchema,
type CampaignInviteAutofillInput,
type CampaignInviteLoginInput,
} from '@/lib/schemas/invite'
import type { CampaignUser, Contact } from '@/payload-types'
import { requireLeadershipConsent, resolveInviteConsent } from '@/utilities/campaignConsent'
import {
acquireCampaignInviteAccountLocks,
acquireCampaignInviteRedemptionContactLock,
consumeCampaignInvite,
findSameContactAccountIDs,
INVALID_CAMPAIGN_INVITE_MESSAGE,
requireCampaignInvitePostgres,
} from '@/utilities/campaignInviteRepository'
import { contactPhoneLockKeys } from '@/utilities/contactPhoneLocks'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
const MISSING_CONSENT_MESSAGE = 'Consentimento ainda não configurado.'
type ProfileInput = {
name: string
phone: string
email?: string | null
gender?: 'feminino' | 'masculino' | 'outro' | 'nao_informado' | null
}
const profileContactData = (data: ProfileInput, originalPhones: Contact['phones']) => ({
name: data.name,
// The typed phone becomes the PRIMARY of the anchored ficha; every number
// the ficha already had is kept (an earlier occurrence of the typed phone
// moves, never duplicates) — the person's other numbers survive the invite.
phones: reorderWithPrimaryPhone(originalPhones, data.phone).map((value) => ({ value })),
...(data.email !== undefined ? { email: data.email } : {}),
...(data.gender !== undefined ? { gender: data.gender } : {}),
})
const consentUpdateData = (
resolution: ReturnType<typeof resolveInviteConsent>,
): {
consent?: number
consentContentHash?: string
consentedAt?: string
} =>
resolution.shouldUpdate
? {
consent: resolution.consentID,
consentContentHash: resolution.consentContentHash,
consentedAt: resolution.consentedAt,
}
: {}
const findReusableLeadershipAccount = async (
payload: Payload,
req: PayloadTransactionRequest,
leadership: { contact: unknown; user?: unknown },
): Promise<CampaignUser | null> => {
const contactID = requireRelationshipId(leadership.contact)
await acquireCampaignInviteRedemptionContactLock(payload, req, contactID)
let accountID = relationshipId(leadership.user)
if (accountID === null) {
const linkedAccountIDs = await findSameContactAccountIDs(payload, req, contactID)
if (linkedAccountIDs.length > 1) throw new Error(INVALID_CAMPAIGN_INVITE_MESSAGE)
accountID = linkedAccountIDs[0] ?? null
}
if (accountID === null) return null
return payload.findByID({
collection: 'campaignUser',
id: accountID,
depth: 0,
overrideAccess: true,
req,
})
}
export const redeemCampaignInviteAutofillRecord = async (
payload: Payload,
input: CampaignInviteAutofillInput,
): Promise<{ ok: true }> => {
const data = campaignInviteAutofillSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
requireCampaignInvitePostgres(payload)
const leadershipID = await consumeCampaignInvite(
payload,
req,
data.token,
'autopreenchimento',
)
// The consumed single-use token scopes this restricted self-service transaction.
const [leadership, consent] = await Promise.all([
payload.findByID({
collection: 'leadership',
id: leadershipID,
depth: 0,
overrideAccess: true,
req,
}),
requireLeadershipConsent(payload, req, MISSING_CONSENT_MESSAGE),
])
const consentResolution = resolveInviteConsent({
existingConsentID: relationshipId(leadership.consent),
existingConsentContentHash: leadership.consentContentHash,
configuredConsentID: consent.id,
configuredConsentContentHash: consent.contentHash,
consentAccepted: data.consentAccepted,
})
const contactID = requireRelationshipId(leadership.contact)
const originalContact = await payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
overrideAccess: true,
req,
})
await acquireCampaignInviteAccountLocks(
payload,
req,
contactPhoneLockKeys(
[primaryPhoneOf(originalContact.phones), data.phone].filter((phone): phone is string =>
Boolean(phone),
),
),
)
// 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,
data: profileContactData(data, originalContact.phones),
depth: 0,
overrideAccess: true,
req,
})
await payload.update({
collection: 'leadership',
id: leadership.id,
data: consentUpdateData(consentResolution),
depth: 0,
overrideAccess: true,
req,
})
return { ok: true }
},
{ beginFailureMessage: 'Não foi possível iniciar a transação do convite.' },
)
}
export const redeemCampaignInviteLoginRecord = async (
payload: Payload,
input: CampaignInviteLoginInput,
): Promise<{ token: string }> => {
const data = campaignInviteLoginSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
requireCampaignInvitePostgres(payload)
const leadershipID = await consumeCampaignInvite(payload, req, data.token, 'login')
// The consumed single-use token scopes all trusted account and profile writes below.
const leadership = await payload.findByID({
collection: 'leadership',
id: leadershipID,
depth: 0,
overrideAccess: true,
req,
})
if (leadership.supportStatus !== 'engajado') {
throw new Error(INVALID_CAMPAIGN_INVITE_MESSAGE)
}
const configuredConsent = await requireLeadershipConsent(
payload,
req,
MISSING_CONSENT_MESSAGE,
)
const consentResolution = resolveInviteConsent({
existingConsentID: relationshipId(leadership.consent),
existingConsentContentHash: leadership.consentContentHash,
configuredConsentID: configuredConsent.id,
configuredConsentContentHash: configuredConsent.contentHash,
consentAccepted: data.consentAccepted,
})
const originalContact = await payload.findByID({
collection: 'contact',
id: requireRelationshipId(leadership.contact),
depth: 0,
overrideAccess: true,
req,
})
let account = await findReusableLeadershipAccount(payload, req, leadership)
await acquireCampaignInviteAccountLocks(payload, req, [
...contactPhoneLockKeys(
[primaryPhoneOf(originalContact.phones), data.phone].filter((phone): phone is string =>
Boolean(phone),
),
),
...(account?.username ? [`account-username:${account.username}`] : []),
`account-username:${data.phone}`,
...(account ? [`invite-redemption-user:${account.id}`] : []),
])
// 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 } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
req,
})
if (accounts.totalDocs > 1) {
throw new Error('Existe mais de uma conta para este celular.')
}
const usernameOwner = accounts.docs[0]
if (usernameOwner && usernameOwner.id !== account?.id) {
throw new Error(INVALID_CAMPAIGN_INVITE_MESSAGE)
}
await payload.update({
collection: 'contact',
id: originalContact.id,
data: profileContactData(data, originalContact.phones),
depth: 0,
overrideAccess: true,
req,
})
const accountData = {
name: data.name,
phone: data.phone,
username: data.phone,
password: data.password,
...(data.email !== undefined ? { email: data.email } : {}),
// C99 — the account points at the ficha that anchors the leadership:
// one person, one ficha, even when the same person also holds a staff
// account. Passed explicitly so the identity hook respects this choice
// instead of syncing the account's phone into whatever ficha it had
// (which would collide with the ficha this flow just updated).
contact: originalContact.id,
}
const hadAccount = Boolean(account)
account = account
? await payload.update({
collection: 'campaignUser',
id: account.id,
data: accountData,
depth: 0,
overrideAccess: true,
req,
})
: await payload.create({
collection: 'campaignUser',
data: { ...accountData, role: 'leader' },
depth: 0,
overrideAccess: true,
req,
})
await payload.update({
collection: 'leadership',
id: leadership.id,
data: {
...consentUpdateData(consentResolution),
user: account.id,
},
depth: 0,
overrideAccess: true,
req,
})
const updatedLeadership = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
overrideAccess: true,
req,
})
if (!hadAccount) {
const { notifyInviteAccepted } = await import('@/utilities/notification/notificationEvents')
await notifyInviteAccepted(
{ payload, context: {}, transactionID: req.transactionID },
updatedLeadership,
)
}
const login = await payload.login({
collection: 'campaignUser',
data: { username: data.phone, password: data.password },
req,
})
if (!login.token) throw new Error('Não foi possível autenticar a conta criada.')
return { token: login.token }
},
{ beginFailureMessage: 'Não foi possível iniciar a transação do convite.' },
)
}