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

100 lines
3.7 KiB
TypeScript

import 'server-only'
import type { Payload } from 'payload'
import { CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE } from '@/lib/campaignInviteClient'
import { primaryPhoneOf } from '@/lib/phone'
import { requireRelationshipId } from '@/lib/relationship'
import { campaignInviteCreateSchema, type CampaignInviteCreateInput } from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { reloadCampaignActor } from '@/utilities/campaignActionContext'
import { requireLeadershipConsent } from '@/utilities/campaignConsent'
import {
buildCampaignInviteWhatsAppLink,
campaignInviteExpiry,
generateCampaignInviteToken,
} from '@/utilities/campaignInvite'
import {
createCampaignInviteRecord,
requireCampaignInvitePostgres,
revokePriorActiveCampaignInvites,
} from '@/utilities/campaignInviteRepository'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
const getFreshInviteCreator = async (
payload: Payload,
actor: CampaignUser,
req: PayloadTransactionRequest,
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
// Staff only (coordinator, advisor, candidate) — matches the collection-level
// `canCreateCampaignInvite` scope; the leadership read below narrows advisors.
if (!isCampaignStaff(currentActor)) {
throw new Error('Somente a coordenação pode criar convites.')
}
return currentActor
}
export const createCampaignInviteForActor = async (
payload: Payload,
actor: CampaignUser,
input: CampaignInviteCreateInput,
inviteBaseURL: string,
): Promise<{ inviteUrl: string; whatsappUrl: string }> => {
const data = campaignInviteCreateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
requireCampaignInvitePostgres(payload)
const currentActor = await getFreshInviteCreator(payload, actor, req)
await requireLeadershipConsent(payload, req, CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE)
const leadership = await payload.findByID({
collection: 'leadership',
id: data.leadership,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
if (data.kind === 'login' && leadership.supportStatus !== 'engajado') {
throw new Error('Convites de acesso exigem uma liderança engajada.')
}
// The fresh staff actor and scoped Leadership authorize these trusted lifecycle reads/writes.
await revokePriorActiveCampaignInvites(payload, req, leadership.id, data.kind)
const generated = generateCampaignInviteToken()
await createCampaignInviteRecord(payload, req, {
tokenHash: generated.tokenHash,
leadershipID: leadership.id,
kind: data.kind,
expiresAt: campaignInviteExpiry().toISOString(),
createdBy: currentActor.id,
})
const contact = await payload.findByID({
collection: 'contact',
id: requireRelationshipId(leadership.contact),
depth: 0,
overrideAccess: true,
req,
})
const primaryPhone = primaryPhoneOf(contact.phones)
if (!primaryPhone) {
throw new Error('Cadastre o celular da liderança antes de gerar o convite.')
}
const inviteUrl = `${inviteBaseURL}/campanha/convite/${generated.token}`
return {
inviteUrl,
whatsappUrl: buildCampaignInviteWhatsAppLink({
phone: primaryPhone,
recipientName: contact.name,
senderName: currentActor.name,
inviteUrl,
kind: data.kind,
}),
}
},
{ beginFailureMessage: 'Não foi possível iniciar a transação do convite.' },
)
}