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
117 lines
3.6 KiB
TypeScript
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,
|
|
}
|
|
})
|
|
}
|