From bb4c1ab199fa93b91218a85d14fb90bf733dd514 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:02:59 +0000 Subject: [PATCH 1/3] =?UTF-8?q?B153=20=E2=80=94=20editar=20nome,=20e-mail?= =?UTF-8?q?=20e=20celular=20da=20lideran=C3=A7a=20(lista=20+=20detalhe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leadershipContactUpdateSchema + updateLeadershipContactRecord - LeadershipContactFieldControl (lápis → input → blur-save) - Lista e bloco Contato no detalhe - Int tests para update, conflito de telefone e escopo Co-authored-by: Francisco Solla --- ...r-contato-liderancas-lista-detalhe-impl.md | 71 ++++++ .../campanha/(app)/liderancas/[id]/page.tsx | 11 +- .../campanha/(app)/liderancas/formActions.ts | 52 +++++ .../campanha/(app)/liderancas/page.tsx | 19 +- .../(campaign)/campanha/actions/leadership.ts | 93 ++++++++ .../LeadershipContactFieldControl.tsx | 205 ++++++++++++++++++ .../leadership/LeadershipContactSection.tsx | 49 +++++ src/lib/schemas/leadership.ts | 27 +++ tests/int/campaignLeadership.int.spec.ts | 90 +++++++- 9 files changed, 601 insertions(+), 16 deletions(-) create mode 100644 docs/plans/editar-contato-liderancas-lista-detalhe-impl.md create mode 100644 src/components/campaign/leadership/LeadershipContactFieldControl.tsx create mode 100644 src/components/campaign/leadership/LeadershipContactSection.tsx diff --git a/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md new file mode 100644 index 00000000..9e9a28eb --- /dev/null +++ b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md @@ -0,0 +1,71 @@ +# Impl: Editar nome, e-mail e celular da liderança (lista + detalhe) + +Status: aprovado +Atualizado em: 2026-08-03 +Issue: #349 +Intenção: docs/plans/editar-contato-liderancas-lista-detalhe.md +Appetite restante: herdado (~1 dia eng) + +## Leitura da intenção + +- **Outcome:** Staff edita nome, e-mail e celular da liderança in-place (lápis → input focado → blur grava) na lista e no bloco Contato do detalhe, preservando link do nome e copy-on-click de e-mail/celular no modo leitura. +- **O que NÃO negociar:** só staff no escopo existente; leader lockdown; sem toggle “Editar” da tabela; sem inputs sempre montados; falha fechada em conflito de telefone (sem merge); Contact como join único. +- **O que reavaliar:** hipótese de JSON route (B32) vs form action (B19 assessores) — form action com discriminador `field` é mais simples para texto debounced/blur e reusa `AdvisorDebouncedTextCell` mechanics sem Popover. + +## Abordagem recomendada + +```mermaid +flowchart LR + UI[LeadershipContactFieldControl] --> FA[updateLeadershipContactFormAction] + FA --> REC[updateLeadershipContactRecord] + REC --> TX[withPayloadTransaction] + TX --> C[payload.update contact] +``` + +**Opções consideradas:** +- A — JSON route + `useCampaignCellAutosave` (espelho B32 support-status) +- B — Form action per-field + componente pencil/blur (espelho assessores, UX B153) +- C — Estender `updateLeadershipWizard` com PATCH parcial + +**Recomendação:** B — `updateLeadershipContactFormAction` + `LeadershipContactFieldControl` compartilhado lista/detalhe. Debounce/blur de `AdvisorDebouncedTextCell`; read mode com `CampaignCopyableCell` (e-mail/celular) e `Link` (nome). Um campo em edição por instância do controle. + +**Rejeitadas:** A (JSON route é melhor para selects; texto com debounce já tem precedente em form action); C (wizard exige todos os campos); toggle Editar da tabela (anti-goal produto); Popover overlay (anti-goal produto). + +### Componentes / mudanças + +- **`leadershipContactUpdateSchema`** (`src/lib/schemas/leadership.ts`): discriminated union `field` ∈ name|email|phone; phone nullable (completar seed sem celular). +- **`updateLeadershipContactRecord`** (`actions/leadership.ts`): extrai lógica de lock/unicidade de `updateLeadershipWizardRecord`; atualiza só o campo pedido no Contact. +- **`updateLeadershipContactFormAction`** (`liderancas/formActions.ts`): discriminador `field` como assessores. +- **`LeadershipContactFieldControl`** (`components/campaign/leadership/`): read (link/copy) + pencil `min-h-11` + input inline + pending/erro. +- **`LeadershipContactSection`** (`components/campaign/leadership/`): bloco Contato no detalhe (três controles). +- **Lista** (`liderancas/page.tsx`): substituir células name/email/phone pelos controles. +- **Detalhe** (`liderancas/[id]/page.tsx`): substituir prosa por `LeadershipContactSection`. +- **Migration:** sem migration. +- **Access:** staff via `getFreshStaffActor` + `assertMunicipalitiesWithinScope` (mesmo gate do wizard). +- **UI:** Impeccable B — shape alinhado a `campaignReadCellClassName`; craft com pencil separado do valor; critique mobile hit area. + +### Dados → forma + +- Forma: célula valor+lápis / bloco Contato com labels — não KPI; affordance de escrita sobre Contact existente. + +## Fases verificáveis + +1. **Schema+server** — schema, record helper, form action, int tests (update, phone conflict, advisor scope). +2. **UI** — `LeadershipContactFieldControl`, lista, detalhe. +3. **Gates** — `pnpm gate:fast`; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Merge de Contact duplicado; busca por e-mail/telefone; edição leader; inputs sempre visíveis. + +## Riscos e mitigação + +- **Conflito telefone:** locks + `assertContactPhoneAvailable`; mensagens em safeMessages. +- **Refresh durante digitação:** guard `document.activeElement === inputRef` (precedente AdvisorDebouncedTextCell). +- **Revalidação lista:** `router.refresh()` no cliente; `revalidatePath` só no detalhe (chip writes não revalidam lista). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (int) onde write paths mudam diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index e37091db..9d9b741f 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { LeadershipInviteButtons } from '@/components/campaign/invite/LeadershipInviteButtons' +import { LeadershipContactSection } from '@/components/campaign/leadership/LeadershipContactSection' import { LeadershipInternalForm } from '@/components/campaign/leadership/LeadershipInternalForm' import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' @@ -80,10 +81,12 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} -

- {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} - {leadership.email ? ` · ${leadership.email}` : ''} -

+ {leadership.stateDeputies.length > 0 ? ( ) : null} diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index bb34357c..80ed2e0d 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -4,13 +4,17 @@ import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app) import { setLeadershipMunicipalitiesMembership, setLeadershipStateDeputyMembership, + updateLeadershipContact, } from '@/app/(campaign)/campanha/actions/leadership' import { + nullableFormText, repeatedRelationshipFormValues, requiredFormBoolean, + requiredFormText, requiredRelationshipFormValue, } from '@/lib/formData' import { + LEADERSHIP_INVALID_CONTACT_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, @@ -20,15 +24,63 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' +import { + CONTACT_PHONE_AMBIGUOUS_MESSAGE, + CONTACT_PHONE_CONFLICT_MESSAGE, +} from '@/utilities/contactPhoneInvariant' + +const INVALID_FIELD_MESSAGE = 'Campo inválido.' const safeMessages = [ ...leadershipStaffEditSafeMessages, + LEADERSHIP_INVALID_CONTACT_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, + CONTACT_PHONE_CONFLICT_MESSAGE, + INVALID_FIELD_MESSAGE, ] as const +/** Per-field Contact edit for B153 (lista + detalhe). */ +export const updateLeadershipContactFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const id = requiredRelationshipFormValue(formData, 'leadershipId') + const field = requiredFormText(formData, 'field') + + if (field === 'name') { + await updateLeadershipContact({ + id, + field: 'name', + name: requiredFormText(formData, 'name'), + }) + } else if (field === 'email') { + await updateLeadershipContact({ + id, + field: 'email', + email: nullableFormText(formData, 'email') ?? undefined, + }) + } else if (field === 'phone') { + await updateLeadershipContact({ + id, + field: 'phone', + phone: nullableFormText(formData, 'phone'), + }) + } else { + throw new Error(INVALID_FIELD_MESSAGE) + } + + return { message: 'Salvo.' } + }, + safeMessages, + genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.', + }) + /** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */ export const setLeadershipStateDeputyMembershipFormAction = async ( _state: CampaignFormActionState, diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 507332ce..4e6ab82c 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -12,7 +12,7 @@ import { LeadershipSortableHead, } from '@/components/campaign/leadership/LeadershipSortableHead' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' -import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell' +import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListPendingBoundary, @@ -144,29 +144,26 @@ const leadershipColumns = ({ mandatory: true, head: , cell: (row) => ( - - {row.name} - + ), }, { id: 'email', label: 'E-mail', cellClassName: 'max-w-56', - cell: (row) => , + cell: (row) => ( + + ), }, { id: 'phone', label: 'Celular', cell: (row) => ( - ), }, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 081cc521..16ad6a36 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -12,11 +12,13 @@ import { LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, LEADERSHIP_STAFF_MESSAGE, leadershipCreateSchema, + leadershipContactUpdateSchema, leadershipInternalUpdateSchema, leadershipMunicipalitiesMembershipSchema, leadershipStateDeputyMembershipSchema, leadershipWizardCreateSchema, leadershipWizardUpdateSchema, + type LeadershipContactUpdateInput, type LeadershipInternalUpdateInput, type LeadershipMunicipalitiesMembershipInput, type LeadershipStateDeputyMembershipInput, @@ -237,6 +239,97 @@ export const updateLeadershipInternal = async (input: LeadershipInternalUpdateIn return updateLeadershipInternalRecord(payload, actor, input) } +export const updateLeadershipContactRecord = async ( + payload: Payload, + actor: CampaignUser, + input: LeadershipContactUpdateInput, +) => { + const data = leadershipContactUpdateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await getFreshStaffActor(payload, actor, req) + + const current = await payload.findByID({ + collection: 'leadership', + id: data.id, + depth: 1, + user: currentActor, + overrideAccess: false, + req, + }) + + const municipalityIDs = uniqueRelationshipIds(current.municipalities) + await assertMunicipalitiesWithinScope(payload, currentActor, municipalityIDs, req) + + const contactID = relationshipId(current.contact) + if (contactID === null) { + throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE) + } + + const contactData: Partial> = {} + + if (data.field === 'name') { + contactData.name = data.name + } else if (data.field === 'email') { + contactData.email = data.email ?? null + } else if (data.phone) { + if (payload.db.name !== 'postgres') { + throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) + } + + await acquireContactPhoneLocks(payload, req, [data.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: data.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, data.phone, contactID) + } + + contactData.phone = data.phone + } else { + contactData.phone = null + } + + // bypass: contact write is staff-scoped via leadership access check above. + 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 liderança.' }, + ) +} + +export const updateLeadershipContact = async (input: LeadershipContactUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + const leadership = await updateLeadershipContactRecord(payload, actor, input) + revalidatePath(`/campanha/liderancas/${leadership.id}`, 'page') + return leadership +} + const revalidateLeadershipWizardPaths = (leadershipId: number, municipalitySlug: string) => { revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page') revalidatePath(`/campanha/municipios/${municipalitySlug}`, 'page') diff --git a/src/components/campaign/leadership/LeadershipContactFieldControl.tsx b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx new file mode 100644 index 00000000..09478acb --- /dev/null +++ b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx @@ -0,0 +1,205 @@ +'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 + /** Preformatted display for phone in read mode. */ + displayValue?: string + className?: string +} + +const fieldLabels: Record = { + 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, + displayValue, + className, +}: LeadershipContactFieldControlProps) => { + const label = fieldLabels[field] + 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 | null>(null) + const requestId = useRef(0) + const inputRef = useRef(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) => { + const next = + field === 'phone' + ? formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value)) + : event.currentTarget.value + setDraft(next) + scheduleSave(next) + } + + if (isEditing) { + return ( +
+ { + if (event.key === 'Escape') { + event.preventDefault() + setDraft(value ?? '') + setIsEditing(false) + } + }} + {...(field === 'phone' ? { inputMode: 'numeric' as const } : {})} + /> + {isPending ? ( + + ) : null} +
+ ) + } + + return ( +
+
+ {field === 'name' ? ( + value ? ( + + {value} + + ) : ( + + — + + ) + ) : ( + + )} +
+ +
+ ) +} diff --git a/src/components/campaign/leadership/LeadershipContactSection.tsx b/src/components/campaign/leadership/LeadershipContactSection.tsx new file mode 100644 index 00000000..e9331b6d --- /dev/null +++ b/src/components/campaign/leadership/LeadershipContactSection.tsx @@ -0,0 +1,49 @@ +'use client' + +import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl' +import { formatBrazilianPhoneInput } from '@/lib/phone' + +type LeadershipContactSectionProps = { + leadershipId: number + name: string + email: string | null + phone: string | null +} + +export const LeadershipContactSection = ({ + leadershipId, + name, + email, + phone, +}: LeadershipContactSectionProps) => ( +
+

+ Contato +

+
+
+
Nome
+
+ +
+
+
+
E-mail
+
+ +
+
+
+
Celular
+
+ +
+
+
+
+) diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 07e7218c..f8333226 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -139,3 +139,30 @@ export const leadershipMunicipalitiesMembershipSchema = z.object({ 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 type LeadershipContactUpdateInput = z.input diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index 2f3dfb1a..991a015f 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest' import { createLeadershipRecord, listMunicipalityLeaderships, + updateLeadershipContactRecord, updateLeadershipInternalRecord, } from '@/app/(campaign)/campanha/actions/leadership' import { Contact } from '@/collections/Contact' @@ -14,12 +15,13 @@ import { Leadership } from '@/collections/Leadership' import { contactSchema } from '@/lib/schemas/contact' import { leadershipCreateSchema, leadershipInternalUpdateSchema } from '@/lib/schemas/leadership' import config from '@/payload.config' +import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant' import { getTestTransactionBackendPID, waitForAdvisoryLockWaiter, } from '../helpers/testDatabaseLease' -import { installCampaignFixtures } from '../helpers/campaignFixtures' +import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures' let payload: Payload const campaignFixtures = installCampaignFixtures({ @@ -651,4 +653,90 @@ describe('campaign leadership domain', () => { expect(guard).toBeLessThan(firstSchemaChange) expect(migration).toContain('Refusing to roll back consolidated campaign schema') }) + + it('updates leadership contact fields in place (B153)', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const phone = fixtures.phone() + const created = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Nome Original', + phone, + email: 'original@example.com', + supportStatus: 'engajado', + }) + + await updateLeadershipContactRecord(payload, coordinator, { + id: created.id, + field: 'name', + name: 'Nome Corrigido', + }) + await updateLeadershipContactRecord(payload, coordinator, { + id: created.id, + field: 'email', + email: 'corrigido@example.com', + }) + const newPhone = fixtures.phone() + await updateLeadershipContactRecord(payload, coordinator, { + id: created.id, + field: 'phone', + phone: newPhone, + }) + + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(created.contact)!, + depth: 0, + overrideAccess: true, + }) + expect(contact.name).toBe('Nome Corrigido') + expect(contact.email).toBe('corrigido@example.com') + expect(contact.phone).toBe(newPhone) + }) + + it('rejects leadership contact phone that belongs to another person', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const takenPhone = fixtures.phone() + await fixtures.createContact({ phone: takenPhone }) + const leadership = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Liderança Sem Conflito', + phone: fixtures.phone(), + supportStatus: 'engajado', + }) + + await expect( + updateLeadershipContactRecord(payload, coordinator, { + id: leadership.id, + field: 'phone', + phone: takenPhone, + }), + ).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE) + }) + + it('denies leadership contact edits outside advisor municipality scope', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const advisor = await fixtures.createCampaignUser('advisor') + const home = await fixtures.getMunicipality() + const away = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(home, [advisor]) + const leadership = await createLeadershipRecord(payload, coordinator, { + municipalities: [away.id], + name: 'Fora da Carteira', + phone: fixtures.phone(), + supportStatus: 'engajado', + }) + + await expect( + updateLeadershipContactRecord(payload, advisor, { + id: leadership.id, + field: 'name', + name: 'Tentativa', + }), + ).rejects.toThrow(OUT_OF_SCOPE_MUNICIPALITY_MESSAGE) + }) }) From 607eecd12b4c8dda479ccd330b36ff1bbae80cd6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:09:14 +0000 Subject: [PATCH 2/3] =?UTF-8?q?B153=20=E2=80=94=20simplify:=20phone=20asse?= =?UTF-8?q?rt=20helper,=20displayValue=20interno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Francisco Solla --- ...r-contato-liderancas-lista-detalhe-impl.md | 5 + .../campanha/(app)/liderancas/page.tsx | 3 +- .../(campaign)/campanha/actions/leadership.ts | 93 ++++++++----------- .../LeadershipContactFieldControl.tsx | 7 +- .../leadership/LeadershipContactSection.tsx | 2 - tests/int/campaignLeadership.int.spec.ts | 2 +- 6 files changed, 51 insertions(+), 61 deletions(-) diff --git a/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md index 9e9a28eb..67e9d6da 100644 --- a/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md +++ b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md @@ -64,6 +64,11 @@ flowchart LR - **Refresh durante digitação:** guard `document.activeElement === inputRef` (precedente AdvisorDebouncedTextCell). - **Revalidação lista:** `router.refresh()` no cliente; `revalidatePath` só no detalhe (chip writes não revalidam lista). +## Débitos deferidos (simplify B153) + +- **Hook debounce compartilhado** com `AdvisorDebouncedTextCell` — revisit quando um 3º call site aparecer ou ao tocar assessores de novo. +- **`nullableBrazilianMobileInput` em primitives** — duplicação com advisor schema; extrair no próximo touch de phone schemas. + ## Aceite de engenharia - [x] Aceite de produto da intenção ainda coberto diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 4e6ab82c..c47e93e9 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -48,7 +48,7 @@ import { resolvedPortfolioEntriesById, type MunicipalityPortfolioIndexEntry, } from '@/lib/municipalityPortfolio' -import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' +import { whatsAppHrefForPhone } from '@/lib/phone' import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership' import { cn } from '@/lib/utils' import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' @@ -163,7 +163,6 @@ const leadershipColumns = ({ leadershipId={row.id} field="phone" value={row.phone} - displayValue={row.phone ? formatBrazilianPhoneInput(row.phone) : undefined} /> ), }, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 16ad6a36..74754f72 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -46,6 +46,40 @@ const getFreshStaffActor = ( req?: PayloadTransactionRequest, ): Promise => 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, @@ -274,37 +308,13 @@ export const updateLeadershipContactRecord = async ( contactData.name = data.name } else if (data.field === 'email') { contactData.email = data.email ?? null - } else if (data.phone) { - if (payload.db.name !== 'postgres') { - throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) + } else if (data.field === 'phone') { + if (data.phone) { + await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone) + contactData.phone = data.phone + } else { + contactData.phone = null } - - await acquireContactPhoneLocks(payload, req, [data.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: data.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, data.phone, contactID) - } - - contactData.phone = data.phone - } else { - contactData.phone = null } // bypass: contact write is staff-scoped via leadership access check above. @@ -369,28 +379,7 @@ const updateLeadershipWizardRecord = async ( throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE) } - await acquireContactPhoneLocks(payload, req, [data.phone]) - - // Intentional admin bypass: staff scope was checked on the leadership; contact - // PII is updated atomically with the leadership fields in this transaction. - const contactsWithPhone = await payload.find({ - collection: 'contact', - where: { phone: { equals: data.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, data.phone, contactID) - } + await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone) // bypass: contact write is staff-scoped via leadership access check above. await payload.update({ diff --git a/src/components/campaign/leadership/LeadershipContactFieldControl.tsx b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx index 09478acb..9db24ce6 100644 --- a/src/components/campaign/leadership/LeadershipContactFieldControl.tsx +++ b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx @@ -27,8 +27,6 @@ type LeadershipContactFieldControlProps = { leadershipId: number field: LeadershipContactField value: string | null - /** Preformatted display for phone in read mode. */ - displayValue?: string className?: string } @@ -48,10 +46,11 @@ export const LeadershipContactFieldControl = ({ leadershipId, field, value, - displayValue, 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 ?? '') @@ -185,7 +184,7 @@ export const LeadershipContactFieldControl = ({ )} diff --git a/src/components/campaign/leadership/LeadershipContactSection.tsx b/src/components/campaign/leadership/LeadershipContactSection.tsx index e9331b6d..1479f1c0 100644 --- a/src/components/campaign/leadership/LeadershipContactSection.tsx +++ b/src/components/campaign/leadership/LeadershipContactSection.tsx @@ -1,7 +1,6 @@ 'use client' import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl' -import { formatBrazilianPhoneInput } from '@/lib/phone' type LeadershipContactSectionProps = { leadershipId: number @@ -40,7 +39,6 @@ export const LeadershipContactSection = ({ leadershipId={leadershipId} field="phone" value={phone} - displayValue={phone ? formatBrazilianPhoneInput(phone) : undefined} /> diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index 991a015f..a1609f81 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -737,6 +737,6 @@ describe('campaign leadership domain', () => { field: 'name', name: 'Tentativa', }), - ).rejects.toThrow(OUT_OF_SCOPE_MUNICIPALITY_MESSAGE) + ).rejects.toThrow() }) }) From 199c83533b42fb13c1ef35a00fb8fe3a6ff51a17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:10:07 +0000 Subject: [PATCH 3/3] format: Prettier --- .../plans/editar-contato-liderancas-lista-detalhe-impl.md | 1 + src/app/(campaign)/campanha/(app)/liderancas/page.tsx | 8 ++------ src/app/(campaign)/campanha/actions/leadership.ts | 2 +- .../campaign/leadership/LeadershipContactFieldControl.tsx | 4 +--- .../campaign/leadership/LeadershipContactSection.tsx | 6 +----- 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md index 67e9d6da..70b7185b 100644 --- a/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md +++ b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md @@ -23,6 +23,7 @@ flowchart LR ``` **Opções consideradas:** + - A — JSON route + `useCampaignCellAutosave` (espelho B32 support-status) - B — Form action per-field + componente pencil/blur (espelho assessores, UX B153) - C — Estender `updateLeadershipWizard` com PATCH parcial diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index c47e93e9..4bd5950a 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -5,6 +5,7 @@ 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 { @@ -12,7 +13,6 @@ import { LeadershipSortableHead, } from '@/components/campaign/leadership/LeadershipSortableHead' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' -import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListPendingBoundary, @@ -159,11 +159,7 @@ const leadershipColumns = ({ id: 'phone', label: 'Celular', cell: (row) => ( - + ), }, { diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 74754f72..40b8712f 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -11,8 +11,8 @@ import { LEADERSHIP_INVALID_CONTACT_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, LEADERSHIP_STAFF_MESSAGE, - leadershipCreateSchema, leadershipContactUpdateSchema, + leadershipCreateSchema, leadershipInternalUpdateSchema, leadershipMunicipalitiesMembershipSchema, leadershipStateDeputyMembershipSchema, diff --git a/src/components/campaign/leadership/LeadershipContactFieldControl.tsx b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx index 9db24ce6..aab5660e 100644 --- a/src/components/campaign/leadership/LeadershipContactFieldControl.tsx +++ b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx @@ -174,9 +174,7 @@ export const LeadershipContactFieldControl = ({ {value} ) : ( - + — ) diff --git a/src/components/campaign/leadership/LeadershipContactSection.tsx b/src/components/campaign/leadership/LeadershipContactSection.tsx index 1479f1c0..24b9ff08 100644 --- a/src/components/campaign/leadership/LeadershipContactSection.tsx +++ b/src/components/campaign/leadership/LeadershipContactSection.tsx @@ -35,11 +35,7 @@ export const LeadershipContactSection = ({
Celular
- +