mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(campanha): B163 — editar contatos das dobradinhas
This commit is contained in:
parent
2d241e0125
commit
033c270a89
49 changed files with 14900 additions and 573 deletions
|
|
@ -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).
|
||||
|
|
|
|||
108
docs/plans/editar-campos-lista-dobradinhas-impl.md
Normal file
108
docs/plans/editar-campos-lista-dobradinhas-impl.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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: [] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
309
src/components/campaign/shared/CampaignInlineEditableCell.tsx
Normal file
309
src/components/campaign/shared/CampaignInlineEditableCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type HomeSearchLeadershipHit = {
|
|||
}
|
||||
|
||||
export type HomeSearchStateDeputyHit = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
party: string | null
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
12908
src/migrations/20260806_082110_add_state_deputy_contact.json
Normal file
12908
src/migrations/20260806_082110_add_state_deputy_contact.json
Normal file
File diff suppressed because it is too large
Load diff
159
src/migrations/20260806_082110_add_state_deputy_contact.ts
Normal file
159
src/migrations/20260806_082110_add_state_deputy_contact.ts
Normal 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");`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}".` }
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
44
src/utilities/stateDeputy/nameInvariant.ts
Normal file
44
src/utilities/stateDeputy/nameInvariant.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
158
tests/unit/campaignInlineEditableCell.unit.spec.tsx
Normal file
158
tests/unit/campaignInlineEditableCell.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue