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

117 lines
3.6 KiB
TypeScript

import 'server-only'
import type { Payload } from 'payload'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { populatedContactName } from '@/lib/relationship'
import {
activityResponsibleTypeLabels,
type ActivityResponsibleCollection,
} from '@/lib/schemas/activity'
import type { CampaignUser } from '@/payload-types'
import { searchActivityLeadershipOptions } from '@/utilities/activityLeadershipOptions'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
export type ActivityResponsibleSearchOption = {
relationTo: ActivityResponsibleCollection
id: number
name: string
typeLabel: string
}
const RESPONSIBLE_SEARCH_LIMIT = 20
const toResponsibleOption = (
relationTo: ActivityResponsibleCollection,
id: number,
name: string,
): ActivityResponsibleSearchOption => ({
relationTo,
id,
name,
typeLabel: activityResponsibleTypeLabels[relationTo],
})
/**
* C90 — one async catalog across the three responsible types: eligible staff,
* engaged leaderships and dobradinhas, each read through the actor's own
* access (`overrideAccess: false`), so a leadership outside an advisor's
* portfolio never shows up as a responsible option.
*/
export const searchActivityResponsibleOptions = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
query: string,
): Promise<ActivityResponsibleSearchOption[]> => {
const normalizedQuery = typeof query === 'string' ? query : ''
if (!isContactSearchQueryReady(normalizedQuery)) return []
const { trimmed, digits } = normalizeContactSearchQuery(normalizedQuery)
const [staff, leaderships, deputies] = await Promise.all([
searchStaffResponsibles(payload, user, trimmed),
searchActivityLeadershipOptions(payload, user, trimmed),
searchStateDeputyResponsibles(payload, user, trimmed, digits),
])
const staffOptions = staff.map(({ id, name }) => toResponsibleOption('campaignUser', id, name))
const leadershipOptions = leaderships.map(({ id, label }) =>
toResponsibleOption('leadership', id, label),
)
const deputyOptions = deputies.map(({ id, name }) => toResponsibleOption('stateDeputy', id, name))
return [...staffOptions, ...leadershipOptions, ...deputyOptions].sort((left, right) =>
left.name.localeCompare(right.name, 'pt-BR'),
)
}
const searchStaffResponsibles = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
trimmed: string,
) => {
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [eligibleCampaignStaffWhere, ...(trimmed ? [{ name: { contains: trimmed } }] : [])],
},
depth: 0,
limit: RESPONSIBLE_SEARCH_LIMIT,
page: 1,
select: { name: true },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name }) => ({ id, name: name ?? `Equipe #${id}` }))
}
const searchStateDeputyResponsibles = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
trimmed: string,
digits: string,
) => {
const result = await payload.find({
collection: 'stateDeputy',
where: {
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phones.value': { contains: digits || trimmed } },
],
},
depth: 1,
limit: RESPONSIBLE_SEARCH_LIMIT,
page: 1,
sort: 'contact.name',
select: { contact: true, party: true },
user,
overrideAccess: false,
})
return result.docs.map((stateDeputy) => {
const plainName = populatedContactName(stateDeputy.contact)
return {
id: stateDeputy.id,
name: stateDeputy.party ? `${plainName} (${stateDeputy.party})` : plainName,
}
})
}