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

106 lines
3.4 KiB
TypeScript

import 'server-only'
import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { primaryPhoneOf } from '@/lib/phone'
import type { CampaignUser, Contact, Municipality } from '@/payload-types'
import { getEngagedLeaderMunicipalityIds } from '@/utilities/campaignAccess'
import { getSupporterRegistrationConsent } from '@/utilities/campaignConsent'
export type LeaderContactListItem = {
id: number
name: string
phone: string | null
city: string | null
municipalityName: string | null
createdAt: string
}
export type LeaderContactsPageView = {
municipalityOptions: RelationOption[]
defaultMunicipalityId: number | null
showMunicipalitySelect: boolean
registrationConsentConfigured: boolean
contacts: LeaderContactListItem[]
}
const toLeaderContactListItem = (supporter: {
id: number
contact: Contact | number
municipality?: Municipality | number | null
createdAt: string
}): LeaderContactListItem => {
const contact = supporter.contact as Contact | number | null | undefined
const municipality = supporter.municipality as Municipality | number | null | undefined
return {
id: supporter.id,
name: typeof contact === 'object' && contact ? contact.name : 'Contato',
phone: typeof contact === 'object' && contact ? primaryPhoneOf(contact.phones) : null,
city: typeof contact === 'object' && contact ? (contact.city ?? null) : null,
municipalityName: typeof municipality === 'object' && municipality ? municipality.name : null,
createdAt: supporter.createdAt,
}
}
/**
* Leaders cannot read the municipality collection (lockdown), so their
* registration scope comes from the engaged leadership's linked municipality
* IDs — the same scope `canCreateSupporter` enforces on the write path.
*/
const loadLeaderMunicipalityOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<RelationOption[]> => {
const ids = await getEngagedLeaderMunicipalityIds(payload, user.id)
if (ids.length === 0) return []
const result = await payload.find({
collection: 'municipality',
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true },
// Intentional bypass: scope already derived from the leader's own leadership.
overrideAccess: true,
})
return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name }))
}
export const loadLeaderContactsPageData = async (
payload: Payload,
user: CampaignUser,
): Promise<LeaderContactsPageView> => {
const [municipalityOptions, registrationConsent, supporters] = await Promise.all([
loadLeaderMunicipalityOptions(payload, user),
getSupporterRegistrationConsent(payload),
payload.find({
collection: 'supporter',
depth: 1,
limit: 100,
pagination: false,
sort: '-createdAt',
select: {
contact: true,
municipality: true,
createdAt: true,
},
user,
overrideAccess: false,
}),
])
const defaultMunicipalityId =
municipalityOptions.length === 1 ? (municipalityOptions[0]?.id ?? null) : null
return {
municipalityOptions,
defaultMunicipalityId,
showMunicipalitySelect: municipalityOptions.length > 1,
registrationConsentConfigured: Boolean(registrationConsent),
contacts: supporters.docs.map(toLeaderContactListItem),
}
}