feat(campanha): B163 — editar contatos das dobradinhas

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-08-06 16:51:48 -03:00
parent 2d241e0125
commit 033c270a89
49 changed files with 14900 additions and 573 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, mantém a invariável de nome/telefone e os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md).
**Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md).
**Recently resolved (2026-08-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md).

View file

@ -0,0 +1,108 @@
# Impl: Editar nome, partido, e-mail e telefone na lista de dobradinhas
Status: aprovado
Atualizado em: 2026-08-06
Issue: #391
Intenção: docs/plans/editar-campos-lista-dobradinhas.md
Appetite restante: ~1–1,5 dia de engenharia; um vertical slice de Contact + edição inline, sem remodelar as relações da lista
## Leitura da intenção
- **Outcome:** staff corrige Nome, Partido, E-mail e Telefone na própria lista de `/campanha/dobradinhas`, com feedback de salvamento, sem perder o link da ficha no texto do nome.
- **O que NÃO negociar:** `Contact` é a fonte de nome/e-mail/telefone; dobradinha nova já nasce com Contact; nome no texto abre a ficha; clique na célula fora do texto edita; partido continua em `stateDeputy`; ID é a chave canônica da ficha; `leader` não acessa a superfície; conflito de telefone falha sem merge.
- **O que reavaliar:** a hipótese de manter `stateDeputy.name` e a rota `[slug]`. Manter nome duplicado criaria drift entre a lista e Contact; manter slug como chave canônica faria rename continuar perigoso.
## Abordagem recomendada
```mermaid
flowchart LR
M[Migration: Contact obrigatório + backfill] --> D[StateDeputy: contact, party, notes, slug legado]
C[Create dobradinha] --> T1[Transação: cria Contact + StateDeputy]
D --> L[Loader depth 1: nome, e-mail, telefone, partido]
L --> U[Controle inline compartilhado]
U --> T2[Action: revalida staff + locks de telefone + transação]
T2 --> P[Atualiza Contact ou StateDeputy]
R[URL /dobradinhas/:id ou slug antigo] --> Q[Resolve por id/slug]
Q --> X[Redirect para /dobradinhas/:id]
```
**Opções consideradas:**
- **A — manter `stateDeputy.name` e espelhar o valor no Contact:** menor diff imediato, mas cria duas fontes de verdade para a pessoa e torna qualquer edição futura um problema de sincronização.
- **B — `Contact` obrigatório, backfill dos existentes, `slug` preservado somente como alias legado:** mantém o padrão de `Leadership.contact`, satisfaz a criação atômica e torna rename independente da URL.
- **C — criar Contact somente no primeiro e-mail/telefone editado:** parece reduzir a migration, mas viola o aceite de Contact na criação e deixa a lista com duas formas de registro.
**Recomendação:** B — o custo irreversível é concentrado numa migration explícita e o restante passa a ter uma única fonte de verdade, sem criar uma collection paralela de pessoa.
**Alternativas rejeitadas:** A porque perpetua drift e o `name` paralelo explicitamente proibido pela intenção; C porque deixa dobradinhas recém-criadas sem Contact e complica o vínculo posterior.
### Decisões de engenharia
- **Schema:** remover `StateDeputy.name`; adicionar `contact` obrigatório e único, seguindo `Leadership.contact`; manter `slug` único e imutável como alias de compatibilidade, nunca como identidade canônica. A ação de criação recebe o nome, cria o Contact e o hook deriva o slug do Contact.
- **Migration:** gerar a migration com `pnpm migrate:create add_state_deputy_contact`, revisar o snapshot JSON e hand-write apenas o backfill necessário. A subida adiciona a coluna nullable, cria um Contact name-only (`state: 'BA'`) por dobradinha existente, liga cada linha, aplica FK/índice único/not-null e remove `state_deputy.name`/índice antigo. A descida restaura `name` a partir do Contact antes de remover a relação, sem apagar Contacts que podem ter sido compartilhados; deve ser protegida contra perda de dados alterados.
- **URL:** renomear o segmento interno para `[id]`, mas o resolver aceita número ou slug legado. Toda leitura por slug redireciona para `/campanha/dobradinhas/<id>`; novos links de dados já resolvidos usam ID. O slug permanece consultável para não quebrar links antigos.
- **Escrita:** criar Contact + StateDeputy numa única `withPayloadTransaction`, passando `req` a todas as operações. A edição de Contact primeiro lê a dobradinha com `user` + `overrideAccess: false`; somente depois usa o bypass administrativo justificado para atualizar o Contact na mesma transação.
- **Telefone:** mover a checagem de lock/ambiguidade hoje privada de `leadership.ts` para o dono da invariável (`contactPhoneInvariant.ts`) e reutilizá-la em Leadership e StateDeputy. `CONTACT_PHONE_CONFLICT_MESSAGE` e `CONTACT_PHONE_AMBIGUOUS_MESSAGE` continuam mensagens seguras e allowlisted.
- **UI:** extrair a máquina de edição de `LeadershipContactFieldControl` para um controle compartilhado de célula. Ele preserva o modo `lápis` de lideranças e adiciona o modo `célula` das dobradinhas; aceita link no valor do nome, formato de telefone, action e nome do campo sem copiar a lógica de autosave. Partido usa a mesma máquina, mas grava somente `stateDeputy.party`.
- **Admin/access:** `contact` pode ser preenchido na criação, mas não alterado por update comum da dobradinha; a edição de pessoa fica nas actions autorizadas. `canReadContacts` passa a incluir Contacts ligados a `stateDeputy` para assessores, sem abrir Contacts de lideranças/apoiadores fora do escopo; coordenador/candidato já são irrestritos e líder continua negado.
### Componentes / mudanças
- **`src/collections/StateDeputy.ts`:** usar `contact` como título/admin column; remover hook/validação dependente de `name`; derivar o alias `slug` do Contact na criação; proteger `contact` e `slug` contra updates de campanha.
- **`src/migrations/<timestamp>_add_state_deputy_contact.{ts,json}` + `src/migrations/index.ts`:** migration de schema e backfill idempotente, sem editar migrations congeladas.
- **`src/lib/schemas/stateDeputy.ts`:** separar nome de criação (destinado ao Contact), update de partido/notas e update inline de partido; manter limites de Contact e mensagens de conflito.
- **`src/lib/schemas/contact.ts` (ou owner equivalente após o depth check):** contrato puro de edição por campo compartilhado por Leadership e StateDeputy; eliminar o contrato duplicado em `leadership.ts` se a extração confirmar os mesmos limites.
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`:** criação transacional, `updateStateDeputyContactRecord`, update inline de partido e revalidação dos caminhos por ID; nenhum caminho aceita `leader` ou grava Contact sem a checagem da dobradinha.
- **`src/app/(campaign)/campanha/actions/leadership.ts` + `src/utilities/contactPhoneInvariant.ts`:** reutilizar o helper genérico de telefone sem alterar a política B153.
- **`src/utilities/access/contacts.ts`:** incluir Contacts de dobradinhas no conjunto de leitura do assessor, com query administrativa somente para resolver IDs de linhas que o acesso de StateDeputy já permite.
- **`src/utilities/stateDeputyData.ts` e `src/utilities/stateDeputyListUrl.ts`:** buscar/sortear/filtrar por `contact.name`, montar `name/email/phone` a partir de `depth: 1` e manter view models mínimos.
- **`src/utilities/campaignRelationOptions.ts`, `src/utilities/homeSearch/searchHomeStateDeputies.ts`, `src/lib/campaignHomeSearchHits.ts` e ferramentas AI:** trocar leituras de `stateDeputy.name` por `contact.name`; carregar ID; manter slug somente onde o contrato legado ainda precisa aceitá-lo.
- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx` (nome final a confirmar no código):** máquina client-safe de edição, foco, blur, debounce existente, Escape, pending/erro e refresh; preservar copy-on-click/lápis de B153 e adicionar trigger por célula.
- **`src/components/campaign/leadership/*`:** usar o controle compartilhado e manter o comportamento visual/funcional já entregue em B153.
- **`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`:** colunas Nome, Partido, E-mail e Telefone; nome como link somente no texto, célula fora do link como editor; placeholders legíveis; relações existentes permanecem intactas.
- **`src/app/(campaign)/campanha/(app)/dobradinhas/[id]/`:** resolver ID/slug legado, redirecionar para ID, exibir bloco Contact editável e deixar o form longo responsável apenas por partido/notas.
- **Links de primeira parte em listas, chips, busca, relação e redirect de criação:** usar ID; contratos AI que ainda recebem slug ficam protegidos pelo fallback/redirect até uma mudança explícita do contrato de ferramenta.
### Dados → forma
- **Forma escolhida:** células de leitura com um único editor ativo por gesto; Nome tem texto-link e área restante editável; Partido/E-mail/Telefone entram em input ao clicar na célula; pending/erro ficam no próprio controle e sucesso chama `router.refresh()`.
- **Por quê:** corresponde ao gesto travado de “edit where you see”, preserva a varredura da lista e evita uma tabela-planilha com inputs sempre montados.
- **Rejeitadas:** toggle global de edição, inputs permanentes e lápis obrigatório nas dobradinhas; todos aumentam o modo mental e divergem do canvas/aceite.
## Fases verificáveis
1. **Tracer / schema + dados:** adicionar `contact` no StateDeputy, escrever/revisar migration e backfill, gerar types, adaptar fixture de StateDeputy para criar Contact name-only e provar que create/rollback não deixa órfão de dobradinha.
2. **Server / access:** implementar criação transacional, edição de Contact/partido com schema e locks, atualizar loader/list/search/AI e cobrir coordenador, assessor, líder negado, telefone conflitante e slug legado.
3. **UI:** extrair o controle compartilhado B153; adicionar as quatro colunas e bloco Contact da ficha; alinhar links para ID e feedback de blur. Validar teclado, foco, placeholder, Escape e responsividade da tabela.
4. **Gates:** rodar focused unit/int após cada slice; depois `pnpm generate:types`, `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, build local/test DB e `pnpm push` conforme o fechamento do pipeline.
## Rabbit holes / Não escopo (engenharia)
- Unificar Contact entre dobradinha, liderança e assessor; a relação fica compatível com esse futuro, mas não haverá merge/reuso automático por nome ou telefone.
- Alterar a política de acesso ou criar Consent para dado interno; não há novo opt-in LGPD nesta superfície.
- Migrar a API de ferramentas AI de slug para ID como um redesign separado; basta que o resolver legado redirecione e que novas superfícies próprias usem ID.
- Criar uma tabela/collection de aliases, editar em massa, debounce novo ou mecanismo de autosave paralelo.
- Reorganizar chips de municípios/lideranças/assessores ou mudar filtros/ordenação além da troca do campo de nome para `contact.name`.
## Riscos e mitigação
- **Backfill destrutivo ou parcialmente aplicado:** migration idempotente, contador/`RAISE NOTICE` de linhas afetadas, teste de fonte e rehearsal no banco local; nunca rodar contra produção manualmente.
- **Contact de assessor não consegue ler a nova coluna:** incluir o vínculo StateDeputy no access de Contact e testar leitura como advisor e negação como leader.
- **Telefone duplicado/ambíguo:** lock advisory dos telefones antigo/novo e consulta administrativa limitada; testar corrida/conflict e não oferecer merge.
- **Nome linkável perde o gesto de célula:** teste de componente para clique no texto versus área externa, com `stopPropagation`, foco e Escape.
- **Rename deixa link antigo quebrado:** slug permanece imutável como alias e a rota aceita slug antes de redirecionar ao ID.
- **View model vaza documento inteiro:** selects explícitos e resumo de Contact; nenhuma nova leitura usa `depth` sem necessidade.
- **Refactor B153 regrede copy/lápis:** testes existentes e novos testes do controle compartilhado devem cobrir ambos os modos antes de trocar a implementação.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] `stateDeputy.name` não permanece como segunda fonte; nome/e-mail/telefone vêm de Contact
- [x] Create e Contact update multi-collection usam transação e `req`
- [x] Local API com actor usa `overrideAccess: false`; bypass do Contact está justificado após row access
- [x] `leader` continua fora e assessor só vê Contacts permitidos
- [x] Telefone usa lock/invariante existente e mensagens seguras
- [x] URLs novas usam ID; slug antigo resolve e redireciona
- [x] Testes de migration, domínio/access e UI previstos
**Self-score decision-quality: 5/5** — decisões caras têm alternativas rejeitadas; o appetite permanece bounded; rabbit holes estão nomeados; o controle B153 e a invariável de telefone são reutilizados; o outcome da intenção não foi alterado.

View file

@ -789,11 +789,11 @@ for (const [slug, entry] of staffRoster) {
const existingDeputies = await payload.find({
collection: 'stateDeputy',
depth: 0,
depth: 1,
limit: 5000,
pagination: false,
overrideAccess: true,
select: { name: true, slug: true },
select: { contact: true, slug: true },
})
const deputiesBySlug = new Map(existingDeputies.docs.map((deputy) => [deputy.slug, deputy]))
@ -1124,9 +1124,20 @@ try {
let deputiesCreated = 0
for (const plan of deputyPlans.values()) {
if (plan.deputyId != null) continue
const contact = await payload.create({
collection: 'contact',
data: {
name: plan.name,
state: 'BA',
city: null,
},
depth: 0,
overrideAccess: true,
req,
})
const created = await payload.create({
collection: 'stateDeputy',
data: { name: plan.name },
data: { contact: contact.id },
depth: 0,
overrideAccess: true,
req,

View file

@ -44,6 +44,7 @@ assertLocalDatabase(
const config = (await import('../src/payload.config.ts')).default
const { provisionOnda0ConsentAndPrivacy } = await import('../src/utilities/onda0Provision.ts')
const { hashConsentContent } = await import('../src/utilities/consentContentHash.ts')
const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts')
const { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } = await import('../src/lib/campaignConsentKeys.ts')
const payload = await getPayload({ config })
@ -151,12 +152,48 @@ try {
},
`organization ${MINIMAL_ORGANIZATION.slug}`,
)
const stateDeputy = await upsert(
'stateDeputy',
{ slug: { equals: MINIMAL_STATE_DEPUTY.slug } },
{ ...MINIMAL_STATE_DEPUTY },
`stateDeputy ${MINIMAL_STATE_DEPUTY.slug}`,
)
let stateDeputy = await findOne('stateDeputy', {
slug: { equals: MINIMAL_STATE_DEPUTY.slug },
})
if (!stateDeputy) {
stateDeputy = await withPayloadTransaction(payload, async ({ req }) => {
const contactResult = await payload.find({
collection: 'contact',
where: { email: { equals: `${MINIMAL_STATE_DEPUTY.slug}@planilha.invalid` } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
const contact =
contactResult.docs[0] ??
(await payload.create({
collection: 'contact',
data: {
name: MINIMAL_STATE_DEPUTY.name,
email: `${MINIMAL_STATE_DEPUTY.slug}@planilha.invalid`,
state: 'BA',
city: null,
},
depth: 0,
overrideAccess: true,
req,
}))
return payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: MINIMAL_STATE_DEPUTY.party },
depth: 0,
overrideAccess: true,
req,
})
})
console.log(`[seed:minimal] created contact ${MINIMAL_STATE_DEPUTY.name}`)
console.log(`[seed:minimal] created stateDeputy ${MINIMAL_STATE_DEPUTY.slug}`)
} else {
console.log(`[seed:minimal] ok (exists) stateDeputy ${MINIMAL_STATE_DEPUTY.slug}`)
}
// 5. Leaderships (contact upserted by phone; leadership unique by contact).
for (const entry of MINIMAL_LEADERSHIPS) {

View file

@ -31,7 +31,7 @@ export const updateStateDeputyFormAction = async (
party: nullableFormText(formData, 'party'),
notes: nullableFormText(formData, 'notes'),
})
revalidatePath('/campanha/dobradinhas/[slug]', 'page')
revalidatePath('/campanha/dobradinhas/[id]', 'page')
return { message: 'Dobradinha atualizada.' }
},
safeMessages: [STATE_DEPUTY_STAFF_MESSAGE],

View file

@ -1,12 +1,13 @@
import config from '@payload-config'
import { HandshakeIcon, MapPinIcon, UserCogIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
import { StateDeputyContactSection } from '@/components/campaign/stateDeputy/StateDeputyContactSection'
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
import { Badge } from '@/components/ui/Badge'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
@ -20,13 +21,13 @@ import {
} from './formActions'
export async function generateMetadata({ params }: StateDeputyDetailPageProps) {
const { slug } = await params
const { id } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const stateDeputy = await loadStateDeputyDetail(payload, user, slug)
const stateDeputy = await loadStateDeputyDetail(payload, user, id)
if (!stateDeputy) return campaignPageMetadata({ title: 'Dobradinha' })
return campaignPageMetadata(
@ -37,18 +38,19 @@ export async function generateMetadata({ params }: StateDeputyDetailPageProps) {
}
type StateDeputyDetailPageProps = {
params: Promise<{ slug: string }>
params: Promise<{ id: string }>
}
export default async function StateDeputyDetailPage({ params }: StateDeputyDetailPageProps) {
const { slug } = await params
const { id } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const stateDeputy = await loadStateDeputyDetail(payload, user, slug)
const stateDeputy = await loadStateDeputyDetail(payload, user, id)
if (!stateDeputy) notFound()
if (String(stateDeputy.id) !== id) redirect(`/campanha/dobradinhas/${stateDeputy.id}`)
// B156 — only coordinator/candidate assign advisors; the rest of staff reads.
const canEditAdvisors = isCampaignUnrestricted(user)
@ -63,6 +65,12 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
: { title: stateDeputy.name }
}
/>
<StateDeputyContactSection
stateDeputyId={stateDeputy.id}
name={stateDeputy.name}
email={stateDeputy.email}
phone={stateDeputy.phone}
/>
{stateDeputy.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>
) : null}

View file

@ -4,10 +4,14 @@ import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/ac
import {
setStateDeputyAdvisorMembership,
setStateDeputyMunicipalitiesBatch,
updateStateDeputyContact,
updateStateDeputyParty,
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
@ -16,15 +20,88 @@ import {
} from '@/lib/schemas/leadership'
import {
STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_INVALID_CONTACT_MESSAGE,
STATE_DEPUTY_INVALID_FIELD_MESSAGE,
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import {
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
CONTACT_PHONE_CONFLICT_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const
const stateDeputyContactSafeMessages = [
STATE_DEPUTY_STAFF_MESSAGE,
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_INVALID_CONTACT_MESSAGE,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
CONTACT_PHONE_CONFLICT_MESSAGE,
] as const
const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
/** Per-field Contact edit for B163 (lista + ficha). */
export const updateStateDeputyContactFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const id = requiredRelationshipFormValue(formData, 'stateDeputyId')
const field = requiredFormText(formData, 'field')
if (field === 'name') {
await updateStateDeputyContact({
id,
field: 'name',
name: requiredFormText(formData, 'name'),
})
} else if (field === 'email') {
await updateStateDeputyContact({
id,
field: 'email',
email: nullableFormText(formData, 'email') ?? undefined,
})
} else if (field === 'phone') {
await updateStateDeputyContact({
id,
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else {
throw new Error(STATE_DEPUTY_INVALID_FIELD_MESSAGE)
}
return { message: 'Salvo.' }
},
safeMessages: stateDeputyContactSafeMessages,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})
/** Inline party edit for B163; notes are deliberately left untouched. */
export const updateStateDeputyPartyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateStateDeputyParty({
id: requiredRelationshipFormValue(formData, 'stateDeputyId'),
party: nullableFormText(formData, 'party'),
})
return { message: 'Salvo.' }
},
safeMessages: stateDeputyPartySafeMessages,
genericMessage: 'Não foi possível salvar o partido. Verifique os dados e tente novamente.',
})
/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
export const setLeadershipStateDeputyMembershipFormAction = async (
_state: CampaignFormActionState,

View file

@ -22,7 +22,7 @@ export const createStateDeputyFormAction = async (
party: optionalFormText(formData, 'party'),
notes: optionalFormText(formData, 'notes'),
}),
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.slug}`,
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.id}`,
safeMessages: [STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_STAFF_MESSAGE],
genericMessage:
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',

View file

@ -5,6 +5,7 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
@ -69,6 +70,8 @@ import {
setLeadershipStateDeputyMembershipFormAction,
setStateDeputyAdvisorMembershipFormAction,
setStateDeputyMunicipalitiesFormAction,
updateStateDeputyContactFormAction,
updateStateDeputyPartyFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('dobradinhas')
@ -118,12 +121,17 @@ const stateDeputyColumns = (
mandatory: true,
head: <StateDeputySortableHead state={state} sortKey="name" />,
cell: (row) => (
<Link
href={`/campanha/dobradinhas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="stateDeputyId"
field="name"
value={row.name}
label="Nome"
formAction={updateStateDeputyContactFormAction}
href={`/campanha/dobradinhas/${row.id}`}
editTrigger="cell"
saveOnChange={false}
/>
),
},
{
@ -137,8 +145,53 @@ const stateDeputyColumns = (
hasNoPartyOption={hasNoPartyOption}
/>
),
cellClassName: 'text-muted-foreground',
cell: (row) => row.party ?? '—',
cellClassName: 'min-w-32',
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="stateDeputyId"
field="party"
value={row.party}
label="Partido"
formAction={updateStateDeputyPartyFormAction}
editTrigger="cell"
saveOnChange={false}
/>
),
},
{
id: 'email',
label: 'E-mail',
cellClassName: 'max-w-56',
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="stateDeputyId"
field="email"
value={row.email}
label="E-mail"
formAction={updateStateDeputyContactFormAction}
editTrigger="cell"
saveOnChange={false}
/>
),
},
{
id: 'phone',
label: 'Telefone',
cellClassName: 'min-w-40',
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="stateDeputyId"
field="phone"
value={row.phone}
label="Telefone"
formAction={updateStateDeputyContactFormAction}
editTrigger="cell"
saveOnChange={false}
/>
),
},
{
id: 'municipalities',

View file

@ -5,7 +5,6 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl'
import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters'
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
import {
@ -13,6 +12,7 @@ import {
LeadershipSortableHead,
} from '@/components/campaign/leadership/LeadershipSortableHead'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
@ -81,6 +81,7 @@ import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
import {
setLeadershipMunicipalitiesFormAction,
setLeadershipStateDeputyMembershipFormAction,
updateLeadershipContactFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('liderancas')
@ -144,7 +145,15 @@ const leadershipColumns = ({
mandatory: true,
head: <LeadershipSortableHead state={state} sortKey="name" />,
cell: (row) => (
<LeadershipContactFieldControl leadershipId={row.id} field="name" value={row.name} />
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="leadershipId"
field="name"
value={row.name}
label="Nome"
formAction={updateLeadershipContactFormAction}
href={`/campanha/liderancas/${row.id}`}
/>
),
},
{
@ -152,14 +161,30 @@ const leadershipColumns = ({
label: 'E-mail',
cellClassName: 'max-w-56',
cell: (row) => (
<LeadershipContactFieldControl leadershipId={row.id} field="email" value={row.email} />
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="leadershipId"
field="email"
value={row.email}
label="E-mail"
formAction={updateLeadershipContactFormAction}
readBehavior="copy"
/>
),
},
{
id: 'phone',
label: 'Celular',
cell: (row) => (
<LeadershipContactFieldControl leadershipId={row.id} field="phone" value={row.phone} />
<CampaignInlineEditableCell
recordId={row.id}
recordIdField="leadershipId"
field="phone"
value={row.phone}
label="Celular"
formAction={updateLeadershipContactFormAction}
readBehavior="copy"
/>
),
},
{
@ -224,7 +249,7 @@ const leadershipColumns = ({
items={row.stateDeputies.map((deputy) => ({
id: deputy.id,
label: deputy.name,
href: `/campanha/dobradinhas/${deputy.slug}`,
href: `/campanha/dobradinhas/${deputy.id}`,
...(deputy.party ? { party: deputy.party } : {}),
}))}
options={stateDeputyOptions}
@ -380,7 +405,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
item: {
id: option.id,
label: option.plainName,
href: `/campanha/dobradinhas/${option.slug}`,
href: `/campanha/dobradinhas/${option.id}`,
...(option.party ? { party: option.party } : {}),
},
})),

View file

@ -32,7 +32,7 @@ import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
acquireContactPhoneLocks,
assertContactPhoneAvailable,
assertContactPhoneWritable,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
import { loadMunicipalityLeadershipSummaries } from '@/utilities/municipality/municipalityViewModels'
@ -49,40 +49,6 @@ const getFreshStaffActor = (
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => reloadStaffActor(payload, actor, LEADERSHIP_STAFF_MESSAGE, req)
const assertLeadershipContactPhoneWritable = async (
payload: Payload,
req: PayloadTransactionRequest,
contactID: number,
phone: string,
) => {
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireContactPhoneLocks(payload, req, [phone])
// Intentional admin bypass: staff scope was checked on the leadership; phone
// uniqueness must see every contact with the number, not only visible rows.
const contactsWithPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
req,
})
if (contactsWithPhone.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
}
const phoneOwner = contactsWithPhone.docs[0]
if (phoneOwner && phoneOwner.id !== contactID) {
await assertContactPhoneAvailable(payload, req, phone, contactID)
}
}
/** Advisors may only link leaderships to municipalities they administer. */
const assertMunicipalitiesWithinScope = async (
payload: Payload,
@ -318,7 +284,7 @@ export const updateLeadershipContactRecord = async (
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone)
await assertContactPhoneWritable(payload, req, contactID, data.phone)
contactData.phone = data.phone
} else {
contactData.phone = null
@ -387,7 +353,7 @@ const updateLeadershipWizardRecord = async (
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
}
await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone)
await assertContactPhoneWritable(payload, req, contactID, data.phone)
// bypass: contact write is staff-scoped via leadership access check above.
await payload.update({
@ -556,11 +522,11 @@ export const setMunicipalityLeadershipMembership = async (input: {
* `/campanha/liderancas` — the list the chip was toggled ON — is deliberately
* NOT here: see `revalidateLeadershipMunicipalityPaths` below for the reason.
*/
const revalidateLeadershipStateDeputyPaths = (leadershipId: number, stateDeputySlug?: string) => {
const revalidateLeadershipStateDeputyPaths = (leadershipId: number, stateDeputyID?: number) => {
revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')
revalidatePath('/campanha/dobradinhas', 'page')
if (stateDeputySlug) {
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
if (stateDeputyID) {
revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
}
}
@ -578,7 +544,7 @@ const LEADERSHIP_RELATION_LOCK_KEYS = {
* The pairing is the point: relation and lock key used to be two independent
* string literals a few lines apart, so a third relation could quietly take a
* lock that guards a different column. The rest of the two delta writes — the
* delta shape, the scope assertion, the slug lookup, the return — is genuinely
* delta shape, the scope assertion, the return — is genuinely
* different in each, and hoisting it would cost six callbacks to share ~24 lines.
*/
const openLeadershipForRelationDelta = async <
@ -642,26 +608,11 @@ export const setLeadershipStateDeputyMembershipRecord = async (
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate —
// skip the slug lookup below, which exists only to target that revalidate.
// No-op: nothing to write, and nothing for the caller to revalidate.
if (nextStateDeputyIDs === null) {
return { leadership: current, stateDeputySlug: undefined }
return { leadership: current, stateDeputyID: undefined }
}
// Intentional admin bypass: only used to resolve the slug of the
// touched deputy for a targeted revalidate; existence is otherwise
// enforced by Payload's relationship validation on `update`.
const stateDeputySlug = (
await payload.findByID({
collection: 'stateDeputy',
id: stateDeputyId,
depth: 0,
select: { slug: true },
overrideAccess: true,
req,
})
).slug
const updated = await payload.update({
collection: 'leadership',
id: leadershipId,
@ -672,7 +623,7 @@ export const setLeadershipStateDeputyMembershipRecord = async (
req,
})
return { leadership: updated, stateDeputySlug }
return { leadership: updated, stateDeputyID: stateDeputyId }
},
{ beginFailureMessage: 'Não foi possível atualizar as dobradinhas.' },
)
@ -682,13 +633,13 @@ export const setLeadershipStateDeputyMembership = async (
input: LeadershipStateDeputyMembershipInput,
) => {
const { payload, actor } = await getCampaignActionContext()
const { leadership, stateDeputySlug } = await setLeadershipStateDeputyMembershipRecord(
const { leadership, stateDeputyID } = await setLeadershipStateDeputyMembershipRecord(
payload,
actor,
input,
)
// No-op writes nothing, so there is nothing to revalidate.
if (stateDeputySlug) revalidateLeadershipStateDeputyPaths(input.leadershipId, stateDeputySlug)
if (stateDeputyID) revalidateLeadershipStateDeputyPaths(input.leadershipId, stateDeputyID)
return leadership
}
@ -749,8 +700,7 @@ export const setLeadershipMunicipalitiesMembershipRecord = async (
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate —
// skip the slug lookup below, which exists only to target that revalidate.
// No-op: nothing to write, and nothing for the caller to revalidate.
if (change === null) {
return { leadership: current, municipalitySlugs: [] }
}

View file

@ -4,24 +4,28 @@ import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE,
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_INVALID_CONTACT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
municipalityStateDeputyCreateSchema,
stateDeputyAdvisorMembershipSchema,
stateDeputyCreateSchema,
stateDeputyMunicipalitiesBatchSchema,
stateDeputyPartyUpdateSchema,
stateDeputyUpdateSchema,
type MunicipalityStateDeputyCreateInput,
type StateDeputyAdvisorMembershipInput,
type StateDeputyCreateInput,
type StateDeputyMunicipalitiesBatchInput,
type StateDeputyPartyUpdateInput,
type StateDeputyUpdateInput,
} from '@/lib/schemas/stateDeputy'
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
import type { CampaignUser } from '@/payload-types'
import type { CampaignUser, Contact } from '@/payload-types'
import {
getCampaignActionContext,
reloadStaffActor,
@ -32,32 +36,84 @@ import {
runStaffEntityMutation,
type StaffEntityPolicy,
} from '@/utilities/campaignEntityActions'
import { assertContactPhoneWritable } from '@/utilities/contactPhoneInvariant'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
const stateDeputyPolicy: StaffEntityPolicy = {
staffMessage: STATE_DEPUTY_STAFF_MESSAGE,
conflictPattern: /state_deputy_(name|slug)|duplicate key/i,
conflictPattern: /state_deputy_(contact|slug)|duplicate key/i,
conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE,
}
type StateDeputyCreationData = {
name: string
party?: string | null
notes?: string | null
}
const createStateDeputyWithContact = async (
payload: Payload,
actor: CampaignUser,
data: StateDeputyCreationData,
req: { transactionID: number | string },
) => {
// Intentional admin bypass: Contact is admin-managed, while this staff action
// owns the atomic Contact ↔ StateDeputy creation.
await assertStateDeputyNameAvailable(payload, req, data.name)
const contact = await payload.create({
collection: 'contact',
data: {
name: data.name,
email: null,
phone: null,
state: 'BA' as Contact['state'],
city: null,
},
depth: 0,
// Intentional admin bypass: this Contact write is owned by the atomic staff action.
overrideAccess: true,
req,
})
return payload.create({
collection: 'stateDeputy',
data: hookFilledCreateData<'stateDeputy'>({
contact: contact.id,
...(data.party === undefined ? {} : { party: data.party }),
...(data.notes === undefined ? {} : { notes: data.notes }),
}),
depth: 0,
user: actor,
overrideAccess: false,
req,
})
}
const createStateDeputyRecord = async (
payload: Payload,
actor: CampaignUser,
input: StateDeputyCreateInput,
) => {
const data = stateDeputyCreateSchema.parse(input)
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
payload.create({
collection: 'stateDeputy',
data: hookFilledCreateData<'stateDeputy'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
}),
)
try {
return await withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
return createStateDeputyWithContact(payload, currentActor, data, req)
},
{ beginFailureMessage: 'Não foi possível iniciar a transação da dobradinha.' },
)
} catch (error) {
const conflict = mapStaffEntityConflict(error, stateDeputyPolicy)
if (conflict) throw conflict
throw error
}
}
const updateStateDeputyRecord = async (
@ -78,14 +134,111 @@ const updateStateDeputyRecord = async (
)
}
export const updateStateDeputyPartyRecord = async (
payload: Payload,
actor: CampaignUser,
input: StateDeputyPartyUpdateInput,
) => {
const { id, party } = stateDeputyPartyUpdateSchema.parse(input)
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
payload.update({
collection: 'stateDeputy',
id,
data: { party },
depth: 0,
user: currentActor,
overrideAccess: false,
}),
)
}
export const updateStateDeputyContactRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFieldUpdateInput,
) => {
const data = contactFieldUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
const current = await payload.findByID({
collection: 'stateDeputy',
id: data.id,
depth: 0,
select: { contact: true },
user: currentActor,
overrideAccess: false,
req,
})
const contactID = relationshipId(current.contact)
if (contactID === null) throw new Error(STATE_DEPUTY_INVALID_CONTACT_MESSAGE)
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
if (data.field === 'name') {
await assertStateDeputyNameAvailable(payload, req, data.name, data.id)
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
await assertContactPhoneWritable(payload, req, contactID, data.phone)
contactData.phone = data.phone
} else {
contactData.phone = null
}
}
// Bypass: the StateDeputy row access above established staff scope.
await payload.update({
collection: 'contact',
id: contactID,
data: contactData,
depth: 0,
overrideAccess: true,
req,
})
return current
},
{ beginFailureMessage: 'Não foi possível atualizar o contato da dobradinha.' },
)
}
export const createStateDeputy = async (input: StateDeputyCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createStateDeputyRecord(payload, actor, input)
}
const revalidateStateDeputyPaths = (stateDeputyID: number) => {
revalidatePath('/campanha/dobradinhas', 'page')
revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
revalidatePath('/campanha/liderancas', 'page')
revalidatePath('/campanha/liderancas/[id]', 'page')
revalidateMunicipalityListPaths({ scope: 'both' })
}
export const updateStateDeputy = async (input: StateDeputyUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updateStateDeputyRecord(payload, actor, input)
const updated = await updateStateDeputyRecord(payload, actor, input)
revalidateStateDeputyPaths(updated.id)
return updated
}
export const updateStateDeputyParty = async (input: StateDeputyPartyUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
const updated = await updateStateDeputyPartyRecord(payload, actor, input)
revalidateStateDeputyPaths(updated.id)
return updated
}
export const updateStateDeputyContact = async (input: ContactFieldUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
const updated = await updateStateDeputyContactRecord(payload, actor, input)
revalidateStateDeputyPaths(updated.id)
return updated
}
/**
@ -95,11 +248,11 @@ export const updateStateDeputy = async (input: StateDeputyUpdateInput) => {
* and the município index for a change already on screen.
*/
const revalidateStateDeputyMunicipalityPaths = (
stateDeputySlug: string | undefined,
stateDeputyID: number | undefined,
municipalitySlugs: readonly string[],
) => {
if (stateDeputySlug) {
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
if (stateDeputyID) {
revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
}
revalidateMunicipalityListPaths({ scope: 'list' })
for (const slug of municipalitySlugs) {
@ -139,7 +292,7 @@ export const setStateDeputyAdvisorMembershipRecord = async (
collection: 'stateDeputy',
id: stateDeputyId,
depth: 0,
select: { advisors: true, slug: true },
select: { advisors: true },
// Intentional admin bypass: the unrestricted role was verified above;
// the `advisors` field's update access is admin-only in the collection
// config, same contract as `setAdvisorMunicipalitiesBatchRecord`. The
@ -155,10 +308,9 @@ export const setStateDeputyAdvisorMembershipRecord = async (
assigned,
)
// No-op: nothing to write, and nothing for the caller to revalidate —
// the slug lookup below exists only to target that revalidate.
// No-op: nothing to write, and nothing for the caller to revalidate.
if (nextAdvisorIDs === null) {
return { stateDeputySlug: undefined }
return { stateDeputyID: undefined }
}
// Same intentional bypass as the read: the unrestricted role was verified
@ -172,7 +324,7 @@ export const setStateDeputyAdvisorMembershipRecord = async (
req,
})
return { stateDeputySlug: stateDeputy.slug }
return { stateDeputyID: stateDeputyId }
},
{ beginFailureMessage: 'Não foi possível atualizar os assessores da dobradinha.' },
)
@ -180,12 +332,12 @@ export const setStateDeputyAdvisorMembershipRecord = async (
export const setStateDeputyAdvisorMembership = async (input: StateDeputyAdvisorMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, actor, input)
const { stateDeputyID } = await setStateDeputyAdvisorMembershipRecord(payload, actor, input)
// The list is deliberately absent: the chip cell that calls this IS on it and
// already shows the toggle. The detail page is the route an actor cannot see
// from here — same reasoning as the leadership twin (B31).
if (stateDeputySlug) revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
return stateDeputySlug
if (stateDeputyID) revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
return stateDeputyID
}
export const setStateDeputyMunicipalitiesBatchRecord = async (
@ -251,28 +403,10 @@ export const setStateDeputyMunicipalitiesBatchRecord = async (
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
}
// Resolved AFTER the loop, and only when something changed: the slug
// exists solely to target the revalidate, which the caller skips on a
// no-op batch — so on a no-op this read was pure waste (the guard the
// `setLeadershipMunicipalitiesMembershipRecord` twin already had).
// Intentional admin bypass: existence is otherwise enforced by Payload's
// relationship validation on `update` (precedent:
// `setLeadershipStateDeputyMembershipRecord`, B31).
const stateDeputySlug =
changedSlugs.length > 0
? (
await payload.findByID({
collection: 'stateDeputy',
id: stateDeputyId,
depth: 0,
select: { slug: true },
overrideAccess: true,
req,
})
).slug
: undefined
return { stateDeputySlug, slugs: changedSlugs }
return {
stateDeputyID: changedSlugs.length > 0 ? stateDeputyId : undefined,
slugs: changedSlugs,
}
},
{ beginFailureMessage: 'Não foi possível atualizar os municípios da dobradinha.' },
)
@ -284,7 +418,7 @@ export const setStateDeputyMunicipalitiesBatch = async (
const { payload, actor } = await getCampaignActionContext()
const result = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, input)
if (result.slugs.length > 0) {
revalidateStateDeputyMunicipalityPaths(result.stateDeputySlug, result.slugs)
revalidateStateDeputyMunicipalityPaths(result.stateDeputyID, result.slugs)
}
return result
}
@ -295,7 +429,7 @@ export const setStateDeputyMunicipalitiesBatch = async (
* and assigns it to the município in the SAME transaction: if the assign
* fails (out-of-scope município, cap), the create rolls back with it — no
* orphan, same contract as `createMunicipalityAdvisorRecord` (B154). The
* unique `name`/`slug` violation is mapped to the safe conflict message; the
* unique `slug` violation is mapped to the safe conflict message; the
* município write re-checks `canUpdateMunicipality` through the same
* `user`-threaded read as the B37 batch.
*/
@ -317,19 +451,12 @@ export const createMunicipalityStateDeputyRecord = async (
let created
try {
created = await payload.create({
collection: 'stateDeputy',
data: hookFilledCreateData<'stateDeputy'>({ name, ...(party ? { party } : {}) }),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
created = await createStateDeputyWithContact(payload, currentActor, { name, party }, req)
} catch (error) {
// Same translation as the shared owner (`runStaffEntityMutation`): the
// pattern covers the insert race, `ValidationError` covers Payload's
// pre-insert unique check — in this create path the only validation
// left after zod IS the unique name/slug check.
// left after zod IS the unique slug check.
const conflict = mapStaffEntityConflict(error, stateDeputyPolicy)
if (conflict) throw conflict
throw error
@ -367,7 +494,7 @@ export const createMunicipalityStateDeputyRecord = async (
return {
stateDeputy: {
id: created.id,
name: created.name,
name,
slug: created.slug,
party: created.party ?? null,
},
@ -382,7 +509,7 @@ export const createMunicipalityStateDeputy = async (input: MunicipalityStateDepu
const { payload, actor } = await getCampaignActionContext()
const result = await createMunicipalityStateDeputyRecord(payload, actor, input)
if (result.municipalitySlug) {
revalidateStateDeputyMunicipalityPaths(result.stateDeputy.slug, [result.municipalitySlug])
revalidateStateDeputyMunicipalityPaths(result.stateDeputy.id, [result.municipalitySlug])
}
return result
}

View file

@ -1,10 +1,12 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone'
import { trimmedText } from '@/lib/text'
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
import {
acquireContactPhoneLocks,
assertContactPhoneAvailable,
} from '@/utilities/contactPhoneInvariant'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
@ -48,6 +50,38 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
return data
}
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (operation !== 'update' || !data || data.name === undefined || !originalDoc) return data
const currentName = trimmedText(originalDoc.name)
const nextName = trimmedText(data.name)
if (nextName === currentName) return data
const linkedDeputy = await req.payload.find({
collection: 'stateDeputy',
where: { contact: { equals: originalDoc.id } },
depth: 0,
limit: 1,
pagination: false,
// Intentional bypass: Contact access is broader than the deputy's scope; this
// lookup only determines whether the name invariant applies.
overrideAccess: true,
req,
})
const stateDeputyID = linkedDeputy.docs[0]?.id
if (stateDeputyID !== undefined) {
await assertStateDeputyNameAvailable(req.payload, req, nextName, stateDeputyID)
}
return data
}
export const Contact: CollectionConfig = {
slug: 'contact',
labels: {
@ -91,7 +125,7 @@ export const Contact: CollectionConfig = {
return data
},
],
beforeChange: [enforceUniqueContactPhone],
beforeChange: [enforceUniqueContactPhone, enforceStateDeputyName],
},
fields: [
{

View file

@ -1,7 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { STATE_DEPUTY_NAME_REQUIRED_MESSAGE } from '@/lib/schemas/stateDeputy'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
@ -16,24 +16,63 @@ import {
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = ({
const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (!data) return data
const name = trimmedText(data.name ?? originalDoc?.name)
const slug = slugify(name)
if (!slug) {
throw new APIError(STATE_DEPUTY_NAME_REQUIRED_MESSAGE, 400)
if (operation === 'update') {
const contactID = relationshipId(data.contact ?? originalDoc?.contact)
if (contactID !== null) {
const contact = await req.payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { name: true },
// Intentional bypass: this read derives the invariant name after the
// authorized StateDeputy relation update.
overrideAccess: true,
req,
})
await assertStateDeputyNameAvailable(req.payload, req, contact.name, originalDoc?.id)
}
data.slug = originalDoc?.slug ?? data.slug
return data
}
if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) {
throw new APIError('O nome da dobradinha não pode ser alterado após a criação.', 409)
const contactID = relationshipId(data.contact)
if (contactID === null) {
throw new APIError('O contato da dobradinha é obrigatório.', 400)
}
data.name = name
data.slug = operation === 'create' ? slug : originalDoc?.slug
return data
return req.payload
.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { name: true },
// Intentional bypass: the create action has already authorized the staff
// actor; this lookup only derives the immutable legacy alias from Contact.
overrideAccess: true,
req,
})
.then((contact) => {
const name = trimmedText(contact.name)
const slug = slugify(name)
if (!slug) {
throw new APIError(STATE_DEPUTY_NAME_REQUIRED_MESSAGE, 400)
}
return assertStateDeputyNameAvailable(req.payload, req, name).then(() => {
data.slug = slug
return data
})
})
}
const validateStateDeputyAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => {
@ -75,8 +114,8 @@ export const StateDeputy: CollectionConfig = {
},
admin: {
group: 'Campanha',
useAsTitle: 'name',
defaultColumns: ['name', 'party', 'updatedAt'],
useAsTitle: 'contact',
defaultColumns: ['contact', 'party', 'updatedAt'],
description:
'Deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças nas fichas correspondentes.',
},
@ -92,14 +131,16 @@ export const StateDeputy: CollectionConfig = {
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
name: 'contact',
type: 'relationship',
relationTo: 'contact',
label: 'Contato',
required: true,
minLength: 2,
maxLength: 160,
unique: true,
index: true,
access: {
update: canSetCampaignSystemField,
},
},
{
name: 'slug',

View file

@ -37,9 +37,9 @@ export const HomeSearchStateDeputyGroup = () => {
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Dobradinhas</h2>
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
{visibleStateDeputies.map((hit) => (
<li key={`state-deputy-${hit.slug}`}>
<li key={`state-deputy-${hit.id}`}>
<HomeSearchHitRow
href={`/campanha/dobradinhas/${hit.slug}`}
href={`/campanha/dobradinhas/${hit.id}`}
primary={hit.name}
secondary={formatStateDeputySecondary(hit.party, hit.municipalityCount)}
/>

View file

@ -1,202 +0,0 @@
'use client'
import { PencilIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import { toast } from 'sonner'
import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
import {
CampaignCopyableCell,
campaignReadCellClassName,
} from '@/components/campaign/shared/CampaignCopyableCell'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
const SAVE_DEBOUNCE_MS = 500
type LeadershipContactField = 'name' | 'email' | 'phone'
type LeadershipContactFieldControlProps = {
leadershipId: number
field: LeadershipContactField
value: string | null
className?: string
}
const fieldLabels: Record<LeadershipContactField, string> = {
name: 'Nome',
email: 'E-mail',
phone: 'Celular',
}
const inputTypeForField = (field: LeadershipContactField): 'text' | 'email' | 'tel' => {
if (field === 'email') return 'email'
if (field === 'phone') return 'tel'
return 'text'
}
export const LeadershipContactFieldControl = ({
leadershipId,
field,
value,
className,
}: LeadershipContactFieldControlProps) => {
const label = fieldLabels[field]
const phoneDisplayValue =
field === 'phone' && value ? formatBrazilianPhoneInput(value) : undefined
const router = useRouter()
const [isEditing, setIsEditing] = useState(false)
const [draft, setDraft] = useState(value ?? '')
const [isPending, setIsPending] = useState(false)
const lastSaved = useRef((value ?? '').trim())
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestId = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current && document.activeElement === inputRef.current) return
const next = value ?? ''
setDraft(next)
lastSaved.current = next.trim()
}, [value, leadershipId, field])
useEffect(() => {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
}, [isEditing])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const save = (nextRaw: string) => {
const displayNext = nextRaw.trim()
if (displayNext === lastSaved.current) {
setIsEditing(false)
return
}
const formData = new FormData()
formData.set('leadershipId', String(leadershipId))
formData.set('field', field)
formData.set(field, nextRaw)
const id = ++requestId.current
setIsPending(true)
void updateLeadershipContactFormAction({}, formData).then((result: CampaignFormActionState) => {
if (id !== requestId.current) return
setIsPending(false)
if (result.status === 'success') {
lastSaved.current = displayNext
setIsEditing(false)
router.refresh()
return
}
toast.error(result.message ?? 'Não foi possível salvar.')
})
}
const scheduleSave = (nextRaw: string) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = null
save(nextRaw)
}, SAVE_DEBOUNCE_MS)
}
const handleBlur = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
save(draft)
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const next =
field === 'phone'
? formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value))
: event.currentTarget.value
setDraft(next)
scheduleSave(next)
}
if (isEditing) {
return (
<div className={cn('relative min-w-40', className)}>
<Input
ref={inputRef}
type={inputTypeForField(field)}
value={draft}
aria-label={label}
aria-busy={isPending}
className={campaignInlineInputClassName}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
setDraft(value ?? '')
setIsEditing(false)
}
}}
{...(field === 'phone' ? { inputMode: 'numeric' as const } : {})}
/>
{isPending ? (
<Spinner className="pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2" />
) : null}
</div>
)
}
return (
<div className={cn('flex min-w-0 items-center gap-0.5', className)}>
<div className="min-w-0 flex-1">
{field === 'name' ? (
value ? (
<Link
href={`/campanha/liderancas/${leadershipId}`}
className={cn(
campaignReadCellClassName,
'font-medium text-primary underline-offset-4 hover:underline',
)}
>
<span className="truncate">{value}</span>
</Link>
) : (
<span className={cn(campaignReadCellClassName, 'text-sm text-muted-foreground')}>
—
</span>
)
) : (
<CampaignCopyableCell
value={value}
label={label}
displayValue={phoneDisplayValue}
className={field === 'phone' ? 'tabular-nums' : undefined}
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={`Editar ${label.toLowerCase()}`}
onClick={() => setIsEditing(true)}
>
<PencilIcon className="size-4" aria-hidden="true" />
</Button>
</div>
)
}

View file

@ -1,6 +1,7 @@
'use client'
import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl'
import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
type LeadershipContactSectionProps = {
leadershipId: number
@ -23,19 +24,43 @@ export const LeadershipContactSection = ({
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Nome</dt>
<dd>
<LeadershipContactFieldControl leadershipId={leadershipId} field="name" value={name} />
<CampaignInlineEditableCell
recordId={leadershipId}
recordIdField="leadershipId"
field="name"
value={name}
label="Nome"
formAction={updateLeadershipContactFormAction}
href={`/campanha/liderancas/${leadershipId}`}
/>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">E-mail</dt>
<dd>
<LeadershipContactFieldControl leadershipId={leadershipId} field="email" value={email} />
<CampaignInlineEditableCell
recordId={leadershipId}
recordIdField="leadershipId"
field="email"
value={email}
label="E-mail"
formAction={updateLeadershipContactFormAction}
readBehavior="copy"
/>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Celular</dt>
<dd>
<LeadershipContactFieldControl leadershipId={leadershipId} field="phone" value={phone} />
<CampaignInlineEditableCell
recordId={leadershipId}
recordIdField="leadershipId"
field="phone"
value={phone}
label="Celular"
formAction={updateLeadershipContactFormAction}
readBehavior="copy"
/>
</dd>
</div>
</dl>

View file

@ -0,0 +1,309 @@
'use client'
import { PencilIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { toast } from 'sonner'
import {
CampaignCopyableCell,
campaignReadCellClassName,
} from '@/components/campaign/shared/CampaignCopyableCell'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
const SAVE_DEBOUNCE_MS = 500
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party'
type CampaignInlineEditableCellProps = {
recordId: number
recordIdField: string
field: CampaignInlineEditableField
value: string | null
label: string
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
href?: string
editTrigger?: 'pencil' | 'cell'
readBehavior?: 'text' | 'copy'
saveOnChange?: boolean
className?: string
}
const inputTypeForField = (field: CampaignInlineEditableField): 'text' | 'email' | 'tel' => {
if (field === 'email') return 'email'
if (field === 'phone') return 'tel'
return 'text'
}
export const CampaignInlineEditableCell = ({
recordId,
recordIdField,
field,
value,
label,
formAction,
href,
editTrigger = 'pencil',
readBehavior = 'text',
saveOnChange = true,
className,
}: CampaignInlineEditableCellProps) => {
const router = useRouter()
const [isEditing, setIsEditing] = useState(false)
const [draft, setDraft] = useState(value ?? '')
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showSavedFeedback, setShowSavedFeedback] = useState(false)
const lastSaved = useRef((value ?? '').trim())
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const savedFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestId = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current && document.activeElement === inputRef.current) return
const next = value ?? ''
setDraft(next)
lastSaved.current = next.trim()
}, [field, recordId, value])
useEffect(() => {
if (!isEditing) return
inputRef.current?.focus()
inputRef.current?.select()
}, [isEditing])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (savedFeedbackTimerRef.current) clearTimeout(savedFeedbackTimerRef.current)
}
}, [])
const startEditing = () => {
setShowSavedFeedback(false)
setErrorMessage(null)
setIsEditing(true)
}
const save = (nextRaw: string) => {
const displayNext = nextRaw.trim()
if (displayNext === lastSaved.current) {
setErrorMessage(null)
setIsEditing(false)
return
}
const formData = new FormData()
formData.set(recordIdField, String(recordId))
formData.set('field', field)
formData.set(field, nextRaw)
const id = ++requestId.current
setIsPending(true)
setShowSavedFeedback(false)
setErrorMessage(null)
void formAction({}, formData)
.then((result) => {
if (id !== requestId.current) return
setIsPending(false)
if (result.status === 'success') {
lastSaved.current = displayNext
setErrorMessage(null)
setIsEditing(false)
setShowSavedFeedback(true)
if (savedFeedbackTimerRef.current) clearTimeout(savedFeedbackTimerRef.current)
savedFeedbackTimerRef.current = setTimeout(() => {
savedFeedbackTimerRef.current = null
setShowSavedFeedback(false)
}, 1800)
router.refresh()
return
}
const message =
result.fieldErrors?.[field]?.[0] ?? result.message ?? 'Não foi possível salvar.'
setErrorMessage(message)
toast.error(message)
})
.catch(() => {
if (id !== requestId.current) return
const message = 'Não foi possível salvar. Tente novamente.'
setIsPending(false)
setShowSavedFeedback(false)
setErrorMessage(message)
toast.error(message)
})
}
const scheduleSave = (nextRaw: string) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = null
save(nextRaw)
}, SAVE_DEBOUNCE_MS)
}
const handleBlur = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
save(draft)
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const next =
field === 'phone'
? formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value))
: event.currentTarget.value
setDraft(next)
if (saveOnChange) scheduleSave(next)
}
const handleReadKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
startEditing()
}
if (isEditing) {
return (
<div className={cn('relative min-w-40', className)}>
<Input
ref={inputRef}
type={inputTypeForField(field)}
value={draft}
aria-label={label}
aria-busy={isPending}
className={campaignInlineInputClassName}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = null
setDraft(value ?? '')
setShowSavedFeedback(false)
setErrorMessage(null)
setIsEditing(false)
}
}}
{...(field === 'phone' ? { inputMode: 'numeric' as const } : {})}
/>
{isPending ? (
<Spinner
className="pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2"
aria-label={`Salvando ${label.toLowerCase()}`}
/>
) : null}
{errorMessage ? (
<p role="alert" className="mt-1 text-xs text-destructive">
{errorMessage}
</p>
) : null}
</div>
)
}
const displayValue = field === 'phone' && value ? formatBrazilianPhoneInput(value) : undefined
const readValue =
href && field === 'name' && value ? (
<Link
href={href}
className={cn(
campaignReadCellClassName,
'inline-flex w-auto max-w-full',
'pointer-events-auto font-medium text-primary underline-offset-4 hover:underline',
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<span className="truncate">{value}</span>
</Link>
) : readBehavior === 'copy' ? (
<CampaignCopyableCell
value={value}
label={label}
displayValue={displayValue}
className={field === 'phone' ? 'tabular-nums' : undefined}
/>
) : (
<span
className={cn(
campaignReadCellClassName,
'text-sm text-muted-foreground',
value ? 'text-foreground' : undefined,
field === 'phone' ? 'tabular-nums' : undefined,
)}
>
{displayValue ?? value ?? '—'}
</span>
)
const savedFeedback = showSavedFeedback ? (
<span role="status" aria-live="polite" className="text-xs text-muted-foreground">
Salvo.
</span>
) : null
if (editTrigger === 'cell') {
if (href && field === 'name') {
return (
<div className={cn('relative min-w-0 w-full', className)}>
<button
type="button"
className="absolute inset-0 z-0 cursor-text rounded-sm text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
aria-label={`Editar ${label.toLowerCase()}`}
onClick={startEditing}
/>
<div className="pointer-events-none relative z-10 flex min-w-0 items-center gap-2">
{readValue}
{savedFeedback}
</div>
</div>
)
}
return (
<div
className={cn('min-w-0 w-full cursor-text', className)}
role="button"
tabIndex={0}
aria-label={`Editar ${label.toLowerCase()}`}
onClick={startEditing}
onKeyDown={handleReadKeyDown}
>
{readValue}
{savedFeedback}
</div>
)
}
return (
<div className={cn('flex min-w-0 items-center gap-0.5', className)}>
<div className="min-w-0 flex-1">{readValue}</div>
{savedFeedback}
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={`Editar ${label.toLowerCase()}`}
onClick={startEditing}
>
<PencilIcon className="size-4" aria-hidden="true" />
</Button>
</div>
)
}

View file

@ -45,7 +45,7 @@ const entryOf = (option: StateDeputyRelationOption): MunicipalityRelationEntry =
label: option.name,
initialsLabel: option.plainName,
searchText: `${option.plainName} ${option.party ?? ''}`,
href: `/campanha/dobradinhas/${option.slug}`,
href: `/campanha/dobradinhas/${option.id}`,
})
const entriesFor = (options: readonly StateDeputyRelationOption[]) => {
@ -98,7 +98,7 @@ export const MunicipalityStateDeputyRelationCell = ({
label: stateDeputyDisplayName(created.name, created.party),
initialsLabel: created.name,
searchText: `${created.name} ${created.party ?? ''}`,
href: `/campanha/dobradinhas/${created.slug}`,
href: `/campanha/dobradinhas/${created.id}`,
},
}
}
@ -123,7 +123,7 @@ export const MunicipalityStateDeputyRelationCell = ({
}
emptyState={<span className="text-sm text-muted-foreground">—</span>}
createLabel={(name) => `Criar dobradinha “${name}”`}
createMaxLength={160}
createMaxLength={120}
sortSelected={false}
onToggle={toggle}
onCreate={create}

View file

@ -14,7 +14,7 @@ export const StateDeputyChips = ({ deputies }: StateDeputyChipsProps) => {
<div className="flex flex-wrap gap-1">
{deputies.map((deputy) => (
<Badge key={deputy.id} variant="outline" asChild>
<Link href={`/campanha/dobradinhas/${deputy.slug}`}>
<Link href={`/campanha/dobradinhas/${deputy.id}`}>
{deputy.name}
{deputy.party ? ` (${deputy.party})` : ''}
</Link>

View file

@ -0,0 +1,65 @@
'use client'
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
type StateDeputyContactSectionProps = {
stateDeputyId: number
name: string
email: string | null
phone: string | null
}
export const StateDeputyContactSection = ({
stateDeputyId,
name,
email,
phone,
}: StateDeputyContactSectionProps) => (
<section aria-labelledby="state-deputy-contact-title" className="flex flex-col gap-3">
<h2 id="state-deputy-contact-title" className="text-base font-medium">
Contato
</h2>
<dl className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Nome</dt>
<dd>
<CampaignInlineEditableCell
recordId={stateDeputyId}
recordIdField="stateDeputyId"
field="name"
value={name}
label="Nome"
formAction={updateStateDeputyContactFormAction}
/>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">E-mail</dt>
<dd>
<CampaignInlineEditableCell
recordId={stateDeputyId}
recordIdField="stateDeputyId"
field="email"
value={email}
label="E-mail"
formAction={updateStateDeputyContactFormAction}
/>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Telefone</dt>
<dd>
<CampaignInlineEditableCell
recordId={stateDeputyId}
recordIdField="stateDeputyId"
field="phone"
value={phone}
label="Telefone"
formAction={updateStateDeputyContactFormAction}
/>
</dd>
</div>
</dl>
</section>
)

View file

@ -31,11 +31,7 @@ export const StateDeputyForm = ({ formAction, initial, initialName }: StateDeput
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
{initial ? <input type="hidden" name="stateDeputyId" value={initial.id} /> : null}
{isEdit ? (
<p className="text-sm text-muted-foreground">
O nome da dobradinha não pode ser alterado após a criação.
</p>
) : (
{!isEdit ? (
<Field>
<FieldLabel htmlFor="state-deputy-name">Nome</FieldLabel>
<Input
@ -43,7 +39,7 @@ export const StateDeputyForm = ({ formAction, initial, initialName }: StateDeput
name="name"
required
minLength={2}
maxLength={160}
maxLength={120}
defaultValue={initialName}
className="min-h-11"
/>
@ -51,7 +47,7 @@ export const StateDeputyForm = ({ formAction, initial, initialName }: StateDeput
<FieldError>{fieldError(state.fieldErrors, 'name')}</FieldError>
) : null}
</Field>
)}
) : null}
<Field>
<FieldLabel htmlFor="state-deputy-party">Partido</FieldLabel>
<Input

View file

@ -35,6 +35,7 @@ export type HomeSearchLeadershipHit = {
}
export type HomeSearchStateDeputyHit = {
id: number
slug: string
name: string
party: string | null

View file

@ -1,6 +1,12 @@
import { CitiesByState } from '@/lib/cities'
import { z } from 'zod'
import {
brazilianMobile,
optionalPersistedEmail,
positiveRelationshipId,
} from '@/lib/schemas/primitives'
export type StateKey = keyof typeof CitiesByState
export const contactSchema = z.object({
@ -31,3 +37,30 @@ export const contactSchema = z.object({
.transform((v) => (v === '' ? undefined : v))
.optional(),
})
const contactPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
const nullableContactPhone = contactPhoneInput.transform((value) =>
value === '' || value === undefined ? null : value,
)
/** Per-field Contact write shared by campaign person joins. */
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
id: positiveRelationshipId,
field: z.literal('name'),
name: z.string().trim().min(2).max(120),
}),
z.object({
id: positiveRelationshipId,
field: z.literal('email'),
email: optionalPersistedEmail,
}),
z.object({
id: positiveRelationshipId,
field: z.literal('phone'),
phone: nullableContactPhone,
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>

View file

@ -1,5 +1,6 @@
import { z } from 'zod'
import { contactFieldUpdateSchema } from '@/lib/schemas/contact'
import {
brazilianMobile,
optionalPersistedEmail,
@ -152,29 +153,7 @@ export type LeadershipMunicipalitiesMembershipInput = z.input<
typeof leadershipMunicipalitiesMembershipSchema
>
const leadershipContactPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
const nullableLeadershipContactPhone = leadershipContactPhoneInput.transform((value) =>
value === '' || value === undefined ? null : value,
)
/** Per-field Contact write for B153 inline edit (lista + detalhe). */
export const leadershipContactUpdateSchema = z.discriminatedUnion('field', [
z.object({
id: positiveRelationshipId,
field: z.literal('name'),
name: z.string().trim().min(2).max(120),
}),
z.object({
id: positiveRelationshipId,
field: z.literal('email'),
email: optionalPersistedEmail,
}),
z.object({
id: positiveRelationshipId,
field: z.literal('phone'),
phone: nullableLeadershipContactPhone,
}),
])
export const leadershipContactUpdateSchema = contactFieldUpdateSchema
export type LeadershipContactUpdateInput = z.input<typeof leadershipContactUpdateSchema>

View file

@ -12,7 +12,7 @@ import {
import { parseStateDeputyNameParty } from '@/lib/stateDeputyNameParty'
export const stateDeputyCreateSchema = z.object({
name: z.string().trim().min(2).max(160),
name: z.string().trim().min(2).max(120),
party: trimmedOptionalText(32),
notes: trimmedOptionalText(4000),
})
@ -23,6 +23,14 @@ export const stateDeputyUpdateSchema = z.object({
notes: trimmedNullableText(4000),
})
export const stateDeputyPartyUpdateSchema = z.object({
id: positiveRelationshipId,
party: trimmedNullableText(32),
})
export const STATE_DEPUTY_INVALID_CONTACT_MESSAGE = 'Contato da dobradinha inválido.'
export const STATE_DEPUTY_INVALID_FIELD_MESSAGE = 'Campo de dobradinha inválido.'
/**
* Refused staff-only write on any dobradinha — thrown by the create/update
* policy AND by the município batch. Named once because every route's
@ -81,8 +89,8 @@ export const municipalityStateDeputyCreateSchema = z
const { name, party } = parseStateDeputyNameParty(input.rawName)
return { municipalityId: input.municipalityId, name, party }
})
.refine((value) => value.name.length >= 2 && value.name.length <= 160, {
message: 'O nome precisa ter entre 2 e 160 caracteres.',
.refine((value) => value.name.length >= 2 && value.name.length <= 120, {
message: 'O nome precisa ter entre 2 e 120 caracteres.',
})
.refine((value) => (value.party ?? '').length <= 32, {
message: 'O partido pode ter no máximo 32 caracteres.',
@ -119,6 +127,7 @@ export const STATE_DEPUTY_ADVISOR_SAFE_MESSAGES = [
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
export type StateDeputyPartyUpdateInput = z.input<typeof stateDeputyPartyUpdateSchema>
export type StateDeputyMunicipalitiesBatchInput = z.input<
typeof stateDeputyMunicipalitiesBatchSchema
>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,159 @@
import { sql, type MigrateDownArgs, type MigrateUpArgs } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'state_deputy'
AND column_name = 'contact_id'
) THEN
ALTER TABLE "state_deputy" ADD COLUMN "contact_id" integer;
END IF;
END $$;
DO $$
DECLARE
state_deputy_row RECORD;
new_contact_id integer;
created_count integer := 0;
has_legacy_name boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'state_deputy'
AND column_name = 'name'
) INTO has_legacy_name;
IF has_legacy_name THEN
FOR state_deputy_row IN
SELECT "id", "name"
FROM "state_deputy"
WHERE "contact_id" IS NULL
ORDER BY "id"
LOOP
INSERT INTO "contact" ("name", "state")
VALUES (state_deputy_row."name", 'BA')
RETURNING "id" INTO new_contact_id;
UPDATE "state_deputy"
SET "contact_id" = new_contact_id
WHERE "id" = state_deputy_row."id";
created_count := created_count + 1;
END LOOP;
END IF;
RAISE NOTICE 'StateDeputy Contact backfill created % contacts.', created_count;
END $$;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "state_deputy" WHERE "contact_id" IS NULL) THEN
RAISE EXCEPTION 'StateDeputy Contact backfill left rows without contact_id';
END IF;
END $$;
DO $$
DECLARE
names_mismatch boolean;
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'state_deputy'
AND column_name = 'name'
) THEN
EXECUTE $check$
SELECT EXISTS (
SELECT 1
FROM "state_deputy" AS state_deputy
JOIN "contact" AS contact ON contact."id" = state_deputy."contact_id"
WHERE contact."name" IS DISTINCT FROM state_deputy."name"
)
$check$ INTO names_mismatch;
IF names_mismatch THEN
RAISE EXCEPTION 'StateDeputy Contact backfill changed a legacy name';
END IF;
END IF;
END $$;
ALTER TABLE "state_deputy" ALTER COLUMN "contact_id" SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'state_deputy_contact_id_contact_id_fk'
) THEN
ALTER TABLE "state_deputy"
ADD CONSTRAINT "state_deputy_contact_id_contact_id_fk"
FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id")
ON DELETE restrict ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "state_deputy_contact_idx"
ON "state_deputy" USING btree ("contact_id");
DROP INDEX IF EXISTS "state_deputy_name_idx";
ALTER TABLE "state_deputy" DROP COLUMN IF EXISTS "name";`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'state_deputy'
AND column_name = 'contact_id'
) AND EXISTS (
SELECT 1
FROM "state_deputy" AS state_deputy
JOIN "contact" AS contact ON contact."id" = state_deputy."contact_id"
GROUP BY contact."name"
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'Cannot roll back StateDeputy Contact migration: Contact names are not unique';
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'state_deputy'
AND column_name = 'contact_id'
) THEN
ALTER TABLE "state_deputy" ADD COLUMN IF NOT EXISTS "name" varchar;
UPDATE "state_deputy" AS state_deputy
SET "name" = contact."name"
FROM "contact" AS contact
WHERE contact."id" = state_deputy."contact_id";
IF EXISTS (SELECT 1 FROM "state_deputy" WHERE "name" IS NULL) THEN
RAISE EXCEPTION 'Cannot roll back StateDeputy Contact migration: a name could not be restored';
END IF;
ALTER TABLE "state_deputy" ALTER COLUMN "name" SET NOT NULL;
DROP INDEX IF EXISTS "state_deputy_contact_idx";
ALTER TABLE "state_deputy"
DROP CONSTRAINT IF EXISTS "state_deputy_contact_id_contact_id_fk";
ALTER TABLE "state_deputy" DROP COLUMN "contact_id";
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "state_deputy_name_idx"
ON "state_deputy" USING btree ("name");`)
}

View file

@ -40,6 +40,7 @@ import * as migration_20260731_014319_add_campaign_notifications from './2026073
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
import * as migration_20260802_230000_strip_engagement_reversal_signals from './20260802_230000_strip_engagement_reversal_signals';
import * as migration_20260804_061017_add_state_deputy_advisors from './20260804_061017_add_state_deputy_advisors';
import * as migration_20260806_082110_add_state_deputy_contact from './20260806_082110_add_state_deputy_contact';
import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
export const migrations = [
@ -251,11 +252,16 @@ export const migrations = [
{
up: migration_20260804_061017_add_state_deputy_advisors.up,
down: migration_20260804_061017_add_state_deputy_advisors.down,
name: '20260804_061017_add_state_deputy_advisors'
name: '20260804_061017_add_state_deputy_advisors',
},
{
up: migration_20260806_082110_add_state_deputy_contact.up,
down: migration_20260806_082110_add_state_deputy_contact.down,
name: '20260806_082110_add_state_deputy_contact',
},
{
up: migration_20260806_120000_c14_remodel_activity_agenda.up,
down: migration_20260806_120000_c14_remodel_activity_agenda.down,
name: '20260806_120000_c14_remodel_activity_agenda'
name: '20260806_120000_c14_remodel_activity_agenda',
},
];

View file

@ -452,7 +452,7 @@ export interface Municipality {
*/
export interface StateDeputy {
id: number;
name: string;
contact: number | Contact;
slug: string;
party?: string | null;
notes?: string | null;
@ -461,6 +461,49 @@ export interface StateDeputy {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
*/
export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
city?: string | null;
postalCode?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* Endpoints Web Push por dispositivo. Não armazena dados biométricos.
*
@ -546,49 +589,6 @@ export interface Leadership {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
*/
export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
city?: string | null;
postalCode?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e atividades apoiadas.
*
@ -1615,7 +1615,7 @@ export interface OrganizationSelect<T extends boolean = true> {
* via the `definition` "stateDeputy_select".
*/
export interface StateDeputySelect<T extends boolean = true> {
name?: T;
contact?: T;
slug?: T;
party?: T;
notes?: T;

View file

@ -53,6 +53,26 @@ const getAccessibleContactIds = (
}
}
// Advisors already read every dobradinha. Include the normalized Contacts
// backing those rows without widening access to other Contacts.
if (currentUser.role === 'advisor' && collections.stateDeputy) {
// Intentional admin bypass: StateDeputy access already scopes the advisor's rows.
const stateDeputyResult = await find({
collection: 'stateDeputy',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: {},
})
for (const doc of stateDeputyResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
// Advisors read contacts of supporters in their municipalities; leaders read
// contacts of the supporters they created — mirroring `canReadSupporter`.
const supporterWhere =

View file

@ -3,6 +3,7 @@ import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
type Dobradinha = {
name: string
@ -29,8 +30,39 @@ export const getDobradinhas = (ctx: AIToolContext) =>
const { payload } = ctx
const andClauses: Where[] = []
let municipalityContext: { name: string; slug: string } | undefined
if (municipality) {
const munResult = await payload.find({
collection: 'municipality',
where: {
or: [
{ name: { equals: municipality } },
{ slug: { equals: municipality.toLowerCase().replace(/\s+/g, '-') } },
],
},
depth: 0,
limit: 1,
pagination: false,
select: { name: true, slug: true, stateDeputies: true },
overrideAccess: false,
user: ctx.user,
})
const mun = munResult.docs[0]
if (!mun) return { message: `Nenhuma dobradinha encontrada em "${municipality}".` }
const stateDeputyIDs = uniqueRelationshipIds(mun.stateDeputies)
if (stateDeputyIDs.length === 0) {
return { message: `Nenhuma dobradinha encontrada em "${municipality}".` }
}
municipalityContext = { name: mun.name, slug: mun.slug }
andClauses.push({ id: { in: stateDeputyIDs } })
}
if (deputyName) {
andClauses.push({ name: { like: deputyName } })
andClauses.push({ 'contact.name': { like: deputyName } })
}
const where: Where = andClauses.length > 0 ? { and: andClauses } : {}
@ -41,57 +73,41 @@ export const getDobradinhas = (ctx: AIToolContext) =>
depth: 1,
limit: 20,
pagination: false,
select: { name: true, party: true, slug: true, notes: true },
sort: 'name',
select: { contact: true, party: true, slug: true, notes: true },
sort: 'contact.name',
overrideAccess: false,
user: ctx.user,
})
let deputies: Dobradinha[] = result.docs.map((doc: Record<string, unknown>) => ({
name: doc.name as string,
party: (doc.party as string) ?? null,
slug: doc.slug as string,
municipalities: [],
notes: (doc.notes as string) ?? null,
}))
// If municipality filter is provided, query the municipality to get its dobradinhas
if (municipality) {
const munResult = await payload.find({
collection: 'municipality',
where: {
or: [
{ name: { equals: municipality } },
{ slug: { equals: municipality.toLowerCase().replace(/\s+/g, '-') } },
],
},
depth: 1,
limit: 1,
const contactIDs = result.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null)
const contactNames = new Map<number, string>()
if (contactIDs.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { id: { in: contactIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true, slug: true, stateDeputies: true },
select: { name: true },
overrideAccess: false,
user: ctx.user,
})
if (munResult.docs.length > 0) {
const mun = munResult.docs[0] as Record<string, unknown>
const linkedDeputies = (mun.stateDeputies as Array<{ name: string }>) ?? []
const linkedNames = new Set(linkedDeputies.map((d) => d.name))
// Enrich deputies with municipality info
for (const deputy of deputies) {
if (linkedNames.has(deputy.name)) {
deputy.municipalities.push({
name: mun.name as string,
slug: mun.slug as string,
})
}
}
deputies = deputies.filter((d) => linkedNames.has(d.name))
}
for (const contact of contacts.docs) contactNames.set(contact.id, contact.name)
}
const deputies: Dobradinha[] = result.docs.map((doc: Record<string, unknown>) => ({
name: populatedContactName(
doc.contact,
contactNames.get(relationshipId(doc.contact) ?? -1) ?? 'Contato',
),
party: (doc.party as string) ?? null,
slug: doc.slug as string,
municipalities: municipalityContext ? [municipalityContext] : [],
notes: (doc.notes as string) ?? null,
}))
if (deputies.length === 0) {
if (municipality) {
return { message: `Nenhuma dobradinha encontrada em "${municipality}".` }

View file

@ -2,6 +2,7 @@ import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { populatedContactName } from '@/lib/relationship'
type SearchHit = {
collection: string
@ -102,22 +103,24 @@ export const searchEntities = (ctx: AIToolContext) =>
const depResult = await payload.find({
collection: 'stateDeputy',
where: {
name: { like: query },
'contact.name': { like: query },
},
depth: 0,
depth: 1,
limit: 5,
pagination: false,
select: { name: true, party: true },
sort: 'name',
select: { contact: true, party: true },
sort: 'contact.name',
overrideAccess: false,
user: ctx.user,
})
for (const doc of depResult.docs as Array<Record<string, unknown>>) {
const name = populatedContactName(doc.contact)
hits.push({
collection: 'stateDeputy',
label: doc.name as string,
label: name,
description: `Dobradinha — ${(doc.party as string) ?? 'sem partido'}`,
href: `/campanha/dobradinhas/${doc.id as number}`,
})
}

View file

@ -48,7 +48,7 @@ export const loadOrganizationOptions = async (
* `name` folds in the party for select/typeahead disambiguation (e.g.
* `RelationMultiSelect`); `plainName`/`party`/`slug` ride along so cell-level
* consumers (B31's "Dobradinhas" column) can build a `StateDeputySummary`
* chip for an optimistic add and link to `/campanha/dobradinhas/<slug>`
* chip for an optimistic add and link to `/campanha/dobradinhas/<id>`
* without a second catalog fetch.
*/
export type StateDeputyRelationOption = RelationOption & {
@ -63,22 +63,25 @@ export const loadStateDeputyOptions = async (
): Promise<StateDeputyRelationOption[]> => {
const result = await payload.find({
collection: 'stateDeputy',
depth: 0,
depth: 1,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true, party: true, slug: true },
sort: 'contact.name',
select: { contact: true, party: true, slug: true },
where: {},
user,
overrideAccess: false,
})
return result.docs.map((stateDeputy) => ({
id: stateDeputy.id,
name: stateDeputy.party ? `${stateDeputy.name} (${stateDeputy.party})` : stateDeputy.name,
plainName: stateDeputy.name,
party: stateDeputy.party ?? null,
slug: stateDeputy.slug,
}))
return result.docs.map((stateDeputy) => {
const plainName = populatedContactName(stateDeputy.contact)
return {
id: stateDeputy.id,
name: stateDeputy.party ? `${plainName} (${stateDeputy.party})` : plainName,
plainName,
party: stateDeputy.party ?? null,
slug: stateDeputy.slug,
}
})
}
/**

View file

@ -3,7 +3,10 @@ import 'server-only'
import type { Payload, PayloadRequest } from 'payload'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import {
acquireTextAdvisoryLocks,
POSTGRES_DEDUP_LOCK_MESSAGE,
} from '@/utilities/postgresTransactionLocks'
export const CONTACT_PHONE_CONFLICT_MESSAGE = 'Já existe outro contato com este celular.'
@ -14,7 +17,7 @@ export const CONTACT_PHONE_CONFLICT_MESSAGE = 'Já existe outro contato com este
export const CONTACT_PHONE_AMBIGUOUS_MESSAGE =
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.'
type ContactPhonePayload = Pick<Payload, 'db' | 'find'>
type ContactPhonePayload = Pick<Payload, 'db' | 'find' | 'findByID'>
type ContactPhoneRequest = PayloadTransactionRequest | PayloadRequest
export const contactPhoneLockKeys = (phones: string[]): string[] =>
@ -52,3 +55,60 @@ export const assertContactPhoneAvailable = async (
})
if (conflicts.totalDocs > 0) throw new Error(conflictMessage)
}
/**
* Checks the phone invariant for an already-authorized Contact update. The
* caller must prove access to the owning campaign join before using the
* intentional admin-bypass reads/writes here.
*/
export const assertContactPhoneWritable = async (
payload: ContactPhonePayload,
req: ContactPhoneRequest,
contactID: number,
phone: string,
): Promise<void> => {
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
const currentContact = await payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { phone: true },
// Intentional admin bypass: the caller has already proved ownership through
// the campaign join; this read only obtains the old value for lock ordering.
overrideAccess: true,
req,
})
const oldPhone = currentContact.phone ?? undefined
// Lock both sides before checking availability. This prevents concurrent
// swaps (A -> B and B -> A) from waiting on each other's hook locks.
await acquireContactPhoneLocks(
payload,
req,
[oldPhone, phone].filter((value): value is string => Boolean(value)),
)
const contactsWithPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
depth: 0,
limit: 2,
pagination: false,
// Intentional admin bypass: phone uniqueness must see every Contact,
// including one outside the actor's campaign scope.
overrideAccess: true,
req,
})
if (contactsWithPhone.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
}
const phoneOwner = contactsWithPhone.docs[0]
if (phoneOwner && phoneOwner.id !== contactID) {
await assertContactPhoneAvailable(payload, req, phone, contactID)
}
}

View file

@ -14,6 +14,7 @@ import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { populatedContactName } from '@/lib/relationship'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignUser } from '@/payload-types'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
@ -39,13 +40,13 @@ export const searchHomeStateDeputies = async (
const result = await payload.find({
collection: 'stateDeputy',
where: {
or: [{ name: { contains: query } }, { party: { contains: query } }],
or: [{ 'contact.name': { contains: query } }, { party: { contains: query } }],
},
depth: 0,
depth: 1,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true, slug: true, party: true },
sort: 'contact.name',
select: { contact: true, slug: true, party: true },
user,
overrideAccess: false,
})
@ -59,7 +60,8 @@ export const searchHomeStateDeputies = async (
}> = []
for (const doc of result.docs) {
const normalizedName = normalizeHomeSearchName(doc.name)
const name = populatedContactName(doc.contact)
const normalizedName = normalizeHomeSearchName(name)
const normalizedParty = doc.party ? normalizeHomeSearchName(doc.party) : ''
const nameMatch = matchesNormalizedAtWordStart(normalizedName, normalizedQuery)
const partyMatch =
@ -69,7 +71,7 @@ export const searchHomeStateDeputies = async (
candidates.push({
normalizedName: nameMatch ? normalizedName : normalizedParty,
slug: doc.slug,
name: doc.name,
name,
party: doc.party ?? null,
id: doc.id,
})
@ -91,6 +93,7 @@ export const searchHomeStateDeputies = async (
normalizedName: row.normalizedName,
tieBreakDesc: municipalityCount,
hit: {
id: row.id,
slug: row.slug,
name: row.name,
party: row.party,

View file

@ -0,0 +1,44 @@
import 'server-only'
import type { Payload, PayloadRequest } from 'payload'
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
type StateDeputyNameRequest = Pick<PayloadRequest, 'transactionID'>
/**
* Keeps the historical unique-name rule at the StateDeputy boundary even
* though the name now lives on Contact and Contact is shared by other joins.
*/
export const assertStateDeputyNameAvailable = async (
payload: Pick<Payload, 'db' | 'find'>,
req: StateDeputyNameRequest,
name: string,
stateDeputyID?: number,
): Promise<void> => {
const normalizedName = name.trim()
if (req.transactionID !== undefined && req.transactionID !== null) {
await acquireTextAdvisoryLocks(payload, req, [`state-deputy-name:${normalizedName}`])
}
const conflicts = await payload.find({
collection: 'stateDeputy',
where: {
and: [
{ 'contact.name': { equals: normalizedName } },
...(stateDeputyID === undefined ? [] : [{ id: { not_equals: stateDeputyID } }]),
],
},
depth: 0,
limit: 1,
pagination: false,
// Intentional bypass: the invariant must see rows outside the current
// advisor scope.
overrideAccess: true,
req,
})
if (conflicts.totalDocs > 0) throw new Error(STATE_DEPUTY_CONFLICT_MESSAGE)
}

View file

@ -22,6 +22,8 @@ type NamedRelationSummary = {
export type StateDeputyRowViewModel = {
id: number
name: string
email: string | null
phone: string | null
slug: string
party: string | null
municipalityIDs: number[]
@ -30,6 +32,21 @@ export type StateDeputyRowViewModel = {
advisors: NamedRelationSummary[]
}
const stateDeputyContactSummary = (
contact: StateDeputy['contact'],
): { id: number; name: string; email: string | null; phone: string | null } => {
if (typeof contact === 'object' && contact !== null) {
return {
id: contact.id,
name: contact.name,
email: contact.email ?? null,
phone: contact.phone ?? null,
}
}
return { id: Number(contact), name: 'Contato', email: null, phone: null }
}
/** Values still reachable under the OTHER active filters (the Partido popover). */
export type StateDeputyListFilterFacets = {
parties: string[]
@ -125,11 +142,11 @@ export const loadStateDeputyListPageData = async (
payload.find({
collection: 'stateDeputy',
where: buildStateDeputyListWhere(state),
depth: 0,
depth: 1,
limit: stateDeputyPageSize,
page: state.page,
sort: resolveStateDeputyListPayloadSort(sort, dir),
select: { name: true, slug: true, party: true, advisors: true },
select: { contact: true, slug: true, party: true, advisors: true },
user,
overrideAccess: false,
}),
@ -199,8 +216,8 @@ export const loadStateDeputyListPageData = async (
return {
rows: result.docs.map((doc) => ({
...stateDeputyContactSummary(doc.contact),
id: doc.id,
name: doc.name,
slug: doc.slug,
party: doc.party ?? null,
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
@ -221,6 +238,8 @@ export type StateDeputySummary = {
}
export type StateDeputyDetailViewModel = StateDeputySummary & {
email: string | null
phone: string | null
notes: string | null
municipalities: Array<{ id: number; name: string; slug: string }>
leaderships: Array<{ id: number; name: string }>
@ -231,18 +250,29 @@ export type StateDeputyDetailViewModel = StateDeputySummary & {
export const loadStateDeputyDetail = async (
payload: Payload,
user: CampaignUser,
slug: string,
identifier: string,
): Promise<StateDeputyDetailViewModel | null> => {
const numericCandidate = /^[1-9]\d*$/.test(identifier) ? Number(identifier) : null
const numericID =
numericCandidate !== null && Number.isSafeInteger(numericCandidate) ? numericCandidate : null
const result = await payload.find({
collection: 'stateDeputy',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
where:
numericID === null
? { slug: { equals: identifier } }
: { or: [{ id: { equals: numericID } }, { slug: { equals: identifier } }] },
depth: 1,
limit: numericID === null ? 1 : 2,
pagination: false,
user,
overrideAccess: false,
})
const stateDeputy = result.docs[0] as StateDeputy | undefined
// Numeric legacy slugs are rare but valid; an actual ID wins when both exist.
const stateDeputy = (
numericID === null
? result.docs[0]
: (result.docs.find((doc) => doc.id === numericID) ?? result.docs[0])
) as StateDeputy | undefined
if (!stateDeputy) return null
const [municipalities, leaderships, advisorSummaries] = await Promise.all([
@ -271,8 +301,8 @@ export const loadStateDeputyDetail = async (
])
return {
...stateDeputyContactSummary(stateDeputy.contact),
id: stateDeputy.id,
name: stateDeputy.name,
slug: stateDeputy.slug,
party: stateDeputy.party ?? null,
notes: stateDeputy.notes ?? null,
@ -298,11 +328,11 @@ export const loadStateDeputySummaries = async (
const result = await payload.find({
collection: 'stateDeputy',
where: { id: { in: ids } },
depth: 0,
depth: 1,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true, slug: true, party: true },
sort: 'contact.name',
select: { contact: true, slug: true, party: true },
// Intentional admin bypass: id lookups for the home-search card, no
// actor-scoping possible or needed (B52 precedent).
overrideAccess: true,
@ -313,7 +343,7 @@ export const loadStateDeputySummaries = async (
doc.id,
{
id: doc.id,
name: doc.name,
name: stateDeputyContactSummary(doc.contact).name,
slug: doc.slug,
party: doc.party ?? null,
},

View file

@ -72,7 +72,10 @@ export const resolveStateDeputyListSort = (
export const resolveStateDeputyListPayloadSort = (
sort: StateDeputyListSortKey,
dir: StateDeputyListSortDirection,
): string => (dir === 'desc' ? `-${sort}` : sort)
): string => {
const path = sort === 'name' ? 'contact.name' : sort
return dir === 'desc' ? `-${path}` : path
}
export const formatStateDeputyListSortSummary = (
sort: StateDeputyListSortKey,
@ -122,7 +125,7 @@ export const parseStateDeputyListParams = (
export const buildStateDeputyListWhere = (state: StateDeputyListState): Where => {
const filters: Where[] = []
if (state.q) filters.push({ name: { contains: state.q } })
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
if (state.parties?.length) {
const namedParties = state.parties.filter((party) => party !== NO_PARTY_FILTER_VALUE)

View file

@ -93,7 +93,9 @@ type VotePledgeInput = Partial<
type OrganizationInput = Partial<
Pick<Organization, 'name' | 'slug' | 'kind' | 'notes' | 'municipalities'>
>
type StateDeputyInput = Partial<Pick<StateDeputy, 'name' | 'slug' | 'party' | 'notes'>>
type StateDeputyInput = Partial<Pick<StateDeputy, 'contact' | 'slug' | 'party' | 'notes'>> & {
name?: string
}
type CampaignDemandInput = Partial<
Pick<
CampaignDemand,
@ -730,13 +732,15 @@ export class CampaignFixtures {
}
async createStateDeputy(input: StateDeputyInput = {}): Promise<StateDeputy> {
const name = input.name ?? this.value('Deputado')
const { contact: inputContact, name, ...stateDeputyInput } = input
const contact =
inputContact ?? (await this.createContact({ name: name ?? this.value('Deputado') }))
const stateDeputy = await this.rootPayload.create({
collection: 'stateDeputy',
data: {
...input,
name,
slug: input.slug ?? this.value('deputado'),
...stateDeputyInput,
contact: relationId(contact),
slug: stateDeputyInput.slug ?? this.value('deputado'),
},
depth: 0,
})

View file

@ -14,7 +14,7 @@ import { loadAdvisorListPageData } from '@/utilities/advisorData'
import { loadDemandListPageData } from '@/utilities/campaignDemandData'
import { loadLeadershipListPageData } from '@/utilities/leadership/leadershipData'
import { loadOrganizationListPageData } from '@/utilities/organizationData'
import { loadStateDeputyListPageData } from '@/utilities/stateDeputyData'
import { loadStateDeputyDetail, loadStateDeputyListPageData } from '@/utilities/stateDeputyData'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/stateDeputyListUrl'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -312,11 +312,12 @@ describe('loadStateDeputyListPageData', () => {
it('narrows by q with zeroed relation counts for a fresh deputy', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' })
const deputyName = fixtures.value('Deputado')
const stateDeputy = await fixtures.createStateDeputy({ name: deputyName, party: 'PT' })
const result = await loadStateDeputyListPageData(payload, coordinator, {
page: 1,
q: stateDeputy.name,
q: deputyName,
})
expect(result.totalDocs).toBe(1)
@ -333,7 +334,8 @@ describe('loadStateDeputyListPageData', () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' })
const deputyName = fixtures.value('Deputado')
const stateDeputy = await fixtures.createStateDeputy({ name: deputyName, party: 'PT' })
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
@ -349,7 +351,7 @@ describe('loadStateDeputyListPageData', () => {
const result = await loadStateDeputyListPageData(payload, coordinator, {
page: 1,
q: stateDeputy.name,
q: deputyName,
})
expect(result.rows[0]).toMatchObject({
@ -438,6 +440,19 @@ describe('loadStateDeputyListPageData', () => {
})
})
describe('loadStateDeputyDetail', () => {
it('resolves an oversized numeric legacy slug without coercing it to an ID', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const name = '12345678901234567890'
const stateDeputy = await fixtures.createStateDeputy({ name })
const result = await loadStateDeputyDetail(payload, coordinator, stateDeputy.slug)
expect(result).toMatchObject({ id: stateDeputy.id, name })
})
})
describe('loadDemandListPageData', () => {
it('lists staff demands with municipality labels, open count and status filter', async () => {
const fixtures = campaignFixtures()

View file

@ -39,14 +39,14 @@ describe('setLeadershipStateDeputyMembershipRecord (B31)', () => {
})
const stateDeputy = await fixtures.createStateDeputy()
const { leadership: assigned, stateDeputySlug } =
const { leadership: assigned, stateDeputyID } =
await setLeadershipStateDeputyMembershipRecord(payload, actor, {
leadershipId: leadership.id,
stateDeputyId: stateDeputy.id,
assigned: true,
})
expect(stateDeputyIds(assigned)).toContain(stateDeputy.id)
expect(stateDeputySlug).toBe(stateDeputy.slug)
expect(stateDeputyID).toBe(stateDeputy.id)
const { leadership: removed } = await setLeadershipStateDeputyMembershipRecord(
payload,

View file

@ -19,6 +19,7 @@ const testDatabaseUrl =
process.env.DATABASE_URL ?? 'postgresql://teqo:teqo@localhost:5432/teqo_test'
const adminUrl = testDatabaseUrl.replace(/\/[^/]+$/, '/postgres')
const consolidatedName = '20260718_010733_consolidate_campaign_schema'
const stateDeputyContactMigrationName = '20260806_082110_add_state_deputy_contact'
type MigrationDatabase = MigrateUpArgs['db']
let databaseSequence = 0
let databaseName = ''
@ -187,4 +188,86 @@ describe('campaign migration existing-schema reconciliation', () => {
`)
expect(sentinel.rows[0]?.count).toBe(1)
})
it('backfills StateDeputy Contacts and remains idempotent', async () => {
if (!database) throw new Error('Disposable database is not initialized.')
const migration = migrations.find(({ name }) => name === stateDeputyContactMigrationName)
if (!migration) throw new Error('StateDeputy Contact migration is not registered.')
await migration.down(stub<MigrateDownArgs>({ db: database }))
await database.execute(sql`
INSERT INTO "state_deputy" ("name", "slug")
VALUES ('Legado Um', 'legado-um'), ('Legado Dois', 'legado-dois')
`)
const legacyRead = await database.execute(sql`
SELECT "name" AS legacy_name
FROM "state_deputy"
WHERE "slug" IN ('legado-um', 'legado-dois')
ORDER BY "id"
`)
expect(legacyRead.rows).toEqual([{ legacy_name: 'Legado Um' }, { legacy_name: 'Legado Dois' }])
await migration.up(stub<MigrateUpArgs>({ db: database }))
const firstRead = await database.execute(sql`
SELECT contact."name" AS contact_name
FROM "state_deputy" AS state_deputy
JOIN "contact" AS contact ON contact."id" = state_deputy."contact_id"
ORDER BY state_deputy."id"
`)
expect(firstRead.rows).toEqual([{ contact_name: 'Legado Um' }, { contact_name: 'Legado Dois' }])
await migration.up(stub<MigrateUpArgs>({ db: database }))
const contactCount = await database.execute(sql`
SELECT count(*)::integer AS count
FROM "contact"
WHERE "name" IN ('Legado Um', 'Legado Dois')
`)
expect(contactCount.rows[0]?.count).toBe(2)
})
it('keeps the required StateDeputy Contact relation from being nulled on delete', async () => {
if (!database) throw new Error('Disposable database is not initialized.')
const result = await database.execute(sql`
SELECT confdeltype
FROM pg_constraint
WHERE conname = 'state_deputy_contact_id_contact_id_fk'
`)
expect(result.rows).toEqual([{ confdeltype: 'r' }])
})
it('refuses rollback after Contact names diverge into a duplicate', async () => {
if (!database) throw new Error('Disposable database is not initialized.')
const migration = migrations.find(({ name }) => name === stateDeputyContactMigrationName)
if (!migration) throw new Error('StateDeputy Contact migration is not registered.')
const contacts = await database.execute(sql`
INSERT INTO "contact" ("name", "state")
VALUES ('Mesmo Nome', 'BA'), ('Outro Nome', 'BA')
RETURNING "id"
`)
await database.execute(sql`
INSERT INTO "state_deputy" ("contact_id", "slug")
VALUES (${contacts.rows[0]?.id}, 'mesmo-nome-um'), (${contacts.rows[1]?.id}, 'mesmo-nome-dois')
`)
await database.execute(sql`
UPDATE "contact"
SET "name" = 'Mesmo Nome'
WHERE "id" = ${contacts.rows[1]?.id}
`)
await expect(migration.down(stub<MigrateDownArgs>({ db: database }))).rejects.toThrow(
'Contact names are not unique',
)
const columns = await database.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'state_deputy'
AND column_name IN ('name', 'contact_id')
ORDER BY column_name
`)
expect(columns.rows).toEqual([{ column_name: 'contact_id' }])
})
})

View file

@ -4,9 +4,14 @@ import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createMunicipalityStateDeputyRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
createMunicipalityStateDeputyRecord,
updateStateDeputyContactRecord,
updateStateDeputyPartyRecord,
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
import config from '@/payload.config'
import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
@ -32,8 +37,8 @@ const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
const stateDeputyByName = async (name: string) => {
const result = await payload.find({
collection: 'stateDeputy',
where: { name: { equals: name } },
depth: 0,
where: { 'contact.name': { equals: name } },
depth: 1,
limit: 1,
pagination: false,
overrideAccess: true,
@ -62,7 +67,7 @@ describe('createMunicipalityStateDeputyRecord (B157)', () => {
fixtures.touchMunicipality(municipality.id)
expect(municipalitySlug).toBe(municipality.slug)
expect(stateDeputy.name).toBe(name)
expect((await stateDeputyByName(name))?.contact).toMatchObject({ name })
expect(stateDeputy.party).toBeNull()
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
},
@ -84,7 +89,7 @@ describe('createMunicipalityStateDeputyRecord (B157)', () => {
})
fixtures.touchMunicipality(municipality.id)
expect(stateDeputy.name).toBe(name)
expect((await stateDeputyByName(name))?.contact).toMatchObject({ name })
expect(stateDeputy.party).toBe(party)
expect((await stateDeputyByName(name))?.party).toBe(party)
})
@ -99,7 +104,7 @@ describe('createMunicipalityStateDeputyRecord (B157)', () => {
municipalityId: municipality.id,
rawName: '(PT)',
}),
).rejects.toThrow(/entre 2 e 160/)
).rejects.toThrow(/entre 2 e 120/)
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
})
@ -107,12 +112,13 @@ describe('createMunicipalityStateDeputyRecord (B157)', () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const existing = await fixtures.createStateDeputy()
const existingName = fixtures.value('Deputado')
await fixtures.createStateDeputy({ name: existingName })
await expect(
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: existing.name,
rawName: existingName,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
@ -133,6 +139,15 @@ describe('createMunicipalityStateDeputyRecord (B157)', () => {
// The create must roll back with the failed assign — no orphan deputy.
expect(await stateDeputyByName(name)).toBeUndefined()
const orphanContacts = await payload.find({
collection: 'contact',
where: { name: { equals: name } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(orphanContacts.totalDocs).toBe(0)
expect(await stateDeputyIdsOf(outside.id)).toEqual([])
})
@ -173,3 +188,112 @@ describe('createMunicipalityStateDeputyRecord (B157)', () => {
)
})
})
describe('StateDeputy contact fields (B163)', () => {
it('updates Contact fields and party without changing the legacy slug', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Nome Original', party: 'PT' })
const legacySlug = stateDeputy.slug
const phone = fixtures.phone()
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'name',
name: 'Nome Corrigido',
})
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'email',
email: 'corrigido@example.com',
})
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'phone',
phone,
})
await updateStateDeputyPartyRecord(payload, coordinator, {
id: stateDeputy.id,
party: 'PSD',
})
const updated = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 1,
overrideAccess: true,
})
expect(updated.slug).toBe(legacySlug)
expect(updated.party).toBe('PSD')
expect(updated.contact).toMatchObject({
name: 'Nome Corrigido',
email: 'corrigido@example.com',
phone,
})
})
it('rejects a phone already owned by another Contact', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Dobradinha Principal' })
const takenPhone = fixtures.phone()
await fixtures.createContact({ phone: takenPhone })
await expect(
updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'phone',
phone: takenPhone,
}),
).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE)
})
it('rejects renaming a dobradinha to another dobradinha name', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
await fixtures.createStateDeputy({ name: 'Nome já usado' })
const stateDeputy = await fixtures.createStateDeputy({ name: 'Nome atual' })
await expect(
updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'name',
name: 'Nome já usado',
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
const unchanged = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 1,
overrideAccess: true,
})
expect(unchanged.contact).toMatchObject({ name: 'Nome atual' })
})
it('keeps the name invariant when an admin edits the linked Contact directly', async () => {
const fixtures = campaignFixtures()
const first = await fixtures.createStateDeputy({ name: 'Nome já usado' })
const second = await fixtures.createStateDeputy({ name: 'Nome atual' })
const secondContactID = typeof second.contact === 'object' ? second.contact.id : second.contact
await expect(
payload.update({
collection: 'contact',
id: secondContactID,
data: { name: 'Nome já usado' },
depth: 0,
overrideAccess: true,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
const unchanged = await payload.findByID({
collection: 'contact',
id: secondContactID,
depth: 0,
overrideAccess: true,
})
expect(unchanged.name).toBe('Nome atual')
expect(first.id).not.toBe(second.id)
})
})

View file

@ -33,12 +33,12 @@ describe('setStateDeputyAdvisorMembershipRecord (B156)', () => {
const stateDeputy = await fixtures.createStateDeputy()
const advisor = await fixtures.createCampaignUser('advisor')
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, actor, {
const { stateDeputyID } = await setStateDeputyAdvisorMembershipRecord(payload, actor, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: true,
})
expect(stateDeputySlug).toBe(stateDeputy.slug)
expect(stateDeputyID).toBe(stateDeputy.id)
const assigned = await payload.findByID({
collection: 'stateDeputy',
@ -117,13 +117,13 @@ describe('setStateDeputyAdvisorMembershipRecord (B156)', () => {
assigned: true,
})
// No-op contract: no slug back (the caller then skips the revalidate).
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
// No-op contract: no id back (the caller then skips the revalidate).
const { stateDeputyID } = await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
advisorId: advisor.id,
assigned: true,
})
expect(stateDeputySlug).toBeUndefined()
expect(stateDeputyID).toBeUndefined()
const after = await payload.findByID({
collection: 'stateDeputy',

View file

@ -0,0 +1,158 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const router = vi.hoisted(() => ({ refresh: vi.fn() }))
const errorToast = vi.hoisted(() => vi.fn())
vi.mock('next/navigation', () => ({ useRouter: () => router }))
vi.mock('sonner', () => ({ toast: { error: errorToast } }))
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
afterEach(cleanup)
beforeEach(() => {
router.refresh.mockReset()
errorToast.mockReset()
})
describe('CampaignInlineEditableCell (B163)', () => {
it('keeps the name link separate from the cell edit trigger', () => {
const formAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
status: 'success',
message: 'Salvo.',
}),
)
render(
<CampaignInlineEditableCell
recordId={7}
recordIdField="stateDeputyId"
field="name"
value="Ana Bastos"
label="Nome"
href="/campanha/dobradinhas/7"
editTrigger="cell"
saveOnChange={false}
formAction={formAction}
/>,
)
const link = screen.getByRole('link', { name: 'Ana Bastos' })
expect(link.getAttribute('href')).toBe('/campanha/dobradinhas/7')
expect(link.closest('[role="button"]')).toBeNull()
expect(link.parentElement?.className).toContain('pointer-events-none')
expect(link.className).toContain('pointer-events-auto')
expect(screen.queryByRole('textbox', { name: 'Nome' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Editar nome' }))
expect(screen.getByRole('textbox', { name: 'Nome' })).toBeTruthy()
})
it('keeps a failed save in the editor and exposes the error locally', async () => {
const formAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
message: 'Telefone já usado.',
}),
)
render(
<CampaignInlineEditableCell
recordId={7}
recordIdField="stateDeputyId"
field="phone"
value="71999991234"
label="Telefone"
editTrigger="cell"
saveOnChange={false}
formAction={formAction}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Editar telefone' }))
const input = screen.getByRole('textbox', { name: 'Telefone' })
fireEvent.change(input, { target: { value: '71999990000' } })
fireEvent.blur(input)
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('Telefone já usado.'),
)
expect(screen.getByRole('textbox', { name: 'Telefone' })).toBeTruthy()
expect(errorToast).toHaveBeenCalledWith('Telefone já usado.')
})
it('renders field validation errors from the action beside the active editor', async () => {
const formAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
fieldErrors: { email: ['E-mail inválido.'] },
}),
)
render(
<CampaignInlineEditableCell
recordId={7}
recordIdField="stateDeputyId"
field="email"
value={null}
label="E-mail"
editTrigger="cell"
saveOnChange={false}
formAction={formAction}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Editar e-mail' }))
const input = screen.getByRole('textbox', { name: 'E-mail' })
fireEvent.change(input, { target: { value: 'invalid' } })
fireEvent.blur(input)
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('E-mail inválido.'))
expect(screen.getByRole('textbox', { name: 'E-mail' })).toBeTruthy()
})
it('shows local success feedback after a saved edit', async () => {
const formAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
status: 'success',
message: 'Salvo.',
}),
)
render(
<CampaignInlineEditableCell
recordId={7}
recordIdField="stateDeputyId"
field="party"
value="PT"
label="Partido"
editTrigger="cell"
saveOnChange={false}
formAction={formAction}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Editar partido' }))
const input = screen.getByRole('textbox', { name: 'Partido' })
fireEvent.change(input, { target: { value: 'PSD' } })
fireEvent.blur(input)
await waitFor(() => expect(screen.getByRole('status').textContent).toContain('Salvo.'))
expect(screen.queryByRole('textbox', { name: 'Partido' })).toBeNull()
expect(router.refresh).toHaveBeenCalled()
})
})

View file

@ -574,7 +574,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/app/(campaign)/campanha/actions/municipality.ts', 1],
['src/app/(campaign)/campanha/actions/password.ts', 2],
['src/app/(campaign)/campanha/actions/profile.ts', 4],
['src/app/(campaign)/campanha/actions/stateDeputy.ts', 1],
['src/app/(campaign)/campanha/actions/stateDeputy.ts', 0],
['src/app/(campaign)/campanha/actions/supporter.ts', 9],
['src/app/(campaign)/campanha/actions/supporterImport.ts', 2],
['src/app/(campaign)/campanha/actions/votePledge.ts', 1],

View file

@ -69,7 +69,7 @@ describe('MunicipalityStateDeputyRelationCell shared editor (B159)', () => {
screen.getByRole('button', { name: 'Editar dobradinhas em Seabra — Beltrana (PSB)' }),
).toBeTruthy()
expect(screen.getByRole('link', { name: 'Beltrana (PSB)' }).getAttribute('href')).toBe(
'/campanha/dobradinhas/beltrana',
'/campanha/dobradinhas/7',
)
})
})

View file

@ -80,7 +80,7 @@ describe('state deputy list URL contract', () => {
})
it('resolves the payload sort string with the descending prefix', () => {
expect(resolveStateDeputyListPayloadSort('name', 'asc')).toBe('name')
expect(resolveStateDeputyListPayloadSort('name', 'asc')).toBe('contact.name')
expect(resolveStateDeputyListPayloadSort('party', 'desc')).toBe('-party')
})