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