mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +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
105 lines
3.2 KiB
TypeScript
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()
|
|
}
|
|
}
|