mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
- 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
100 lines
3.7 KiB
TypeScript
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.' },
|
|
)
|
|
}
|