teqo-backup/src/utilities/campaignInvitePageData.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

105 lines
3.2 KiB
TypeScript

import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import type { Consent } from '@/payload-types'
import { getLeadershipConsent } from '@/utilities/campaignConsent'
import type { CampaignInviteKind } from '@/utilities/campaignInvite'
import { findActiveCampaignInvite } from '@/utilities/campaignInviteRepository'
type CampaignInviteProfile = {
name: string
phone: string
email: string | null
gender: 'feminino' | 'masculino' | 'outro' | 'nao_informado' | null
}
export type CampaignInvitePageData =
| { status: 'invalid' }
| {
status: 'valid'
kind: CampaignInviteKind
profile: CampaignInviteProfile
requiresConsent: boolean
consentData: Consent['text']
}
const invalidInvite = (): CampaignInvitePageData => ({ status: 'invalid' })
export const getCampaignInviteConsentState = async (
payload: Payload,
): Promise<{ configured: boolean }> => ({
configured: Boolean(await getLeadershipConsent(payload)),
})
export const getCampaignInvitePageData = async (
payload: Payload,
token: string,
): Promise<CampaignInvitePageData> => {
if (token.length < 20 || token.length > 256) return invalidInvite()
try {
const invite = await findActiveCampaignInvite(payload, token)
const leadershipID = relationshipId(invite?.leadership)
if (!invite || leadershipID === null) return invalidInvite()
// Intentional admin bypass (this read and the contact read below): the
// invitee is ANONYMOUS — the signed token resolved above is the
// authorization, and the page must render the invited person's own data
// before any session exists.
const leadership = await payload.findByID({
collection: 'leadership',
id: leadershipID,
depth: 0,
select: {
contact: true,
supportStatus: true,
consent: true,
consentContentHash: true,
},
overrideAccess: true,
})
if (invite.kind === 'login' && leadership.supportStatus !== 'engajado') {
return invalidInvite()
}
const contactID = relationshipId(leadership.contact)
if (contactID === null) return invalidInvite()
const [contact, consent] = await Promise.all([
payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: {
name: true,
phones: { value: true },
email: true,
gender: true,
},
// Intentional admin bypass: same anonymous-invitee policy as the
// leadership read above — the token is the authorization.
overrideAccess: true,
}),
getLeadershipConsent(payload),
])
if (!consent) return invalidInvite()
return {
status: 'valid',
kind: invite.kind,
profile: {
name: contact.name,
phone: primaryPhoneOf(contact.phones) ?? '',
email: contact.email ?? null,
gender: contact.gender ?? null,
},
requiresConsent:
relationshipId(leadership.consent) !== consent.id ||
leadership.consentContentHash !== consent.contentHash,
consentData: consent.text,
}
} catch {
return invalidInvite()
}
}