Merge pull request #351 from fsolla/cursor/pool-i349-c89de3ae-f9e7

B153 — Editar nome, e-mail e celular da liderança (lista + detalhe)
This commit is contained in:
Francisco Solla 2026-08-03 11:54:41 -03:00 committed by GitHub
commit d9db139719
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 608 additions and 42 deletions

View file

@ -0,0 +1,77 @@
# 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).
## 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
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (int) onde write paths mudam

View file

@ -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'}
</Badge>
</div>
<p className="text-muted-foreground">
{leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'}
{leadership.email ? ` · ${leadership.email}` : ''}
</p>
<LeadershipContactSection
leadershipId={leadership.id}
name={leadership.name}
email={leadership.email}
phone={leadership.phone}
/>
{leadership.stateDeputies.length > 0 ? (
<StateDeputyChips deputies={leadership.stateDeputies} />
) : null}

View file

@ -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<CampaignFormActionState> =>
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,

View file

@ -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 { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
@ -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'
@ -144,30 +144,22 @@ const leadershipColumns = ({
mandatory: true,
head: <LeadershipSortableHead state={state} sortKey="name" />,
cell: (row) => (
<Link
href={`/campanha/liderancas/${row.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
<LeadershipContactFieldControl leadershipId={row.id} field="name" value={row.name} />
),
},
{
id: 'email',
label: 'E-mail',
cellClassName: 'max-w-56',
cell: (row) => <CampaignCopyableCell value={row.email} label="E-mail" />,
cell: (row) => (
<LeadershipContactFieldControl leadershipId={row.id} field="email" value={row.email} />
),
},
{
id: 'phone',
label: 'Celular',
cell: (row) => (
<CampaignCopyableCell
value={row.phone}
label="Celular"
displayValue={row.phone ? formatBrazilianPhoneInput(row.phone) : undefined}
className="tabular-nums"
/>
<LeadershipContactFieldControl leadershipId={row.id} field="phone" value={row.phone} />
),
},
{

View file

@ -11,12 +11,14 @@ import {
LEADERSHIP_INVALID_CONTACT_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
leadershipContactUpdateSchema,
leadershipCreateSchema,
leadershipInternalUpdateSchema,
leadershipMunicipalitiesMembershipSchema,
leadershipStateDeputyMembershipSchema,
leadershipWizardCreateSchema,
leadershipWizardUpdateSchema,
type LeadershipContactUpdateInput,
type LeadershipInternalUpdateInput,
type LeadershipMunicipalitiesMembershipInput,
type LeadershipStateDeputyMembershipInput,
@ -44,6 +46,40 @@ 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,
@ -237,6 +273,73 @@ 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<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
if (data.field === 'name') {
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone)
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')
@ -276,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({

View file

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

View file

@ -0,0 +1,43 @@
'use client'
import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl'
type LeadershipContactSectionProps = {
leadershipId: number
name: string
email: string | null
phone: string | null
}
export const LeadershipContactSection = ({
leadershipId,
name,
email,
phone,
}: LeadershipContactSectionProps) => (
<section aria-labelledby="leadership-contact-title" className="flex flex-col gap-3">
<h2 id="leadership-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>
<LeadershipContactFieldControl leadershipId={leadershipId} field="name" value={name} />
</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} />
</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} />
</dd>
</div>
</dl>
</section>
)

View file

@ -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<typeof leadershipContactUpdateSchema>

View file

@ -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()
})
})