refactor(C128): fixes do /simplify — locks por pessoa no exit destrutivo, undo-toast suprimido, guard anti-race

- Assessora/StateDeputy: advisory lock por pessoa (person-assessora /
  person-state-deputy) serializa enumerate→decide→write do exit destrutivo
  (dois removes concorrentes de chips distintos deixavam zumbi com carteira
  vazia — P1 do reviewer A); enumeração da carteira usa getAdvisorMunicipalityIds
  canônico (F1).
- RelationChipCell: commitGuard retorna boolean | 'destructive'; o veredicto
  destrutivo suprime o toast 'Desfazer' do lote (re-add só re-criaria a
  entidade vazia — votos/conta já foram) — P2 A+B.
- PeopleMunicipalityCell: sentinela manifestPending (dois guards concorrentes
  sobrescreviam o resolver e a 1ª remoção morria em silêncio — P2-1) + cleanup
  no unmount resolve false (P2-2) + extraFormFields memoizado; minItems órfã
  removida.
- personDelete: bloco batch redundante de autorias removido (o loop com
  deleteCampaignUserAccount já cobria — 4N DELETEs no-op — F4).
- getPersonCapacityExitManifestAction: actor reloaded (papel rebaixado não lê
  manifest que o write recusaria — P2-A).
- Revalidação: setPersonStateDeputyMunicipalities ganha
  revalidateMunicipalityListPaths (paridade com o twin B37 — F2).
- Testes: int +2 (cascata de autorias no exit da Assessora; undo re-cria a
  liderança)
This commit is contained in:
Francisco Solla 2026-08-12 00:58:32 -03:00
parent c0645067f1
commit 5e797d8641
6 changed files with 187 additions and 81 deletions

View file

@ -39,7 +39,7 @@ import {
} from '@/lib/schemas/personDelete'
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import {
getCampaignActionContext,
reloadStaffActor,
@ -255,6 +255,14 @@ export const setPersonAssessoraMembershipRecord = async (
async ({ req }) => {
await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, req)
// Serializes every Assessora write of the SAME person: the destructive
// exit (last municipality) decides "did the carteira empty?" from an
// enumeration, and two concurrent removals of distinct chips would each
// see a non-empty remainder and leave a zombie account with an empty
// carteira — invisible to the lifecycle. The lock makes the
// enumerate→decide→toggle/delete sequence atomic per person.
await acquireTextAdvisoryLocks(payload, req, [`person-assessora:${contactId}`])
const accounts = await payload.find({
collection: 'campaignUser',
where: { contact: { equals: contactId } },
@ -319,19 +327,9 @@ export const setPersonAssessoraMembershipRecord = async (
// empty the carteira deletes the account (with its authored rows and
// assessorado links) — the confirmation dialog listed the manifest.
if (!assigned) {
const current = await payload.find({
collection: 'municipality',
where: { advisors: { contains: accountId } },
depth: 0,
limit: 0,
pagination: false,
// Intentional bypass: the unrestricted gate above is the
// authorization; this read only enumerates the current carteira
// (docs always carry their id).
overrideAccess: true,
req,
})
const currentMunicipalityIDs = current.docs.map((doc) => doc.id)
// Canonical "advisors contains account" lookup, same helper the
// advisor scope uses everywhere else.
const currentMunicipalityIDs = await getAdvisorMunicipalityIds(payload, accountId, req)
const next = currentMunicipalityIDs.filter((id) => !municipalityIds.includes(id))
if (next.length === 0) {
const slugs = await municipalitySlugsOf(payload, req, currentMunicipalityIDs)
@ -524,6 +522,11 @@ export const setPersonStateDeputyMunicipalitiesRecord = async (
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
// Same per-person serialization as the Assessora lifecycle: the
// destructive exit (last municipality) decides from an enumeration, and
// concurrent removals of distinct chips would leave a zombie dobradinha.
await acquireTextAdvisoryLocks(payload, req, [`person-state-deputy:${contactId}`])
const deputies = await payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactId } },
@ -629,18 +632,27 @@ export const setPersonStateDeputyMunicipalities = async (
revalidatePath('/campanha/dobradinhas', 'page')
if (stateDeputyID !== null) revalidatePath(`/campanha/dobradinhas/${stateDeputyID}`, 'page')
for (const slug of slugs) revalidatePath(`/campanha/municipios/${slug}`, 'page')
// Same surface as the B37 twin: the list and the B147 v2 render the
// dobradinha chips per município.
revalidateMunicipalityListPaths({ scope: 'list' })
}
/**
* C128 — read-only preview for the destructive-exit confirmation dialog
* (`PeopleCapacityExitDialog`): what the exit removes, verbatim. The same
* scope guards the exit write runs inside its transaction — the dialog can
* never describe a destruction the write would refuse.
* never describe a destruction the write would refuse. The actor is reloaded
* fresh (same discipline as every record function): a downgraded or removed
* role must not read the manifest the write would refuse.
*/
export const getPersonCapacityExitManifestAction = async (input: unknown) => {
const data = personCapacityExitSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
return loadPersonCapacityExitManifest(payload, actor, data)
const currentActor =
data.capacity === 'account'
? await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE)
: await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE)
return loadPersonCapacityExitManifest(payload, currentActor, data)
}
/**

View file

@ -1,6 +1,6 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { getPersonCapacityExitManifestAction } from '@/app/(campaign)/campanha/actions/person'
@ -32,8 +32,6 @@ type PeopleMunicipalityCellProps = {
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
/** Ids the actor may ADD (administered carteira for an advisor). */
addableIds?: ReadonlySet<number>
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
minItems?: number
/** Read-only chips (actor may see the relation but not edit it). */
readOnly?: boolean
commitAction: (
@ -72,7 +70,6 @@ export const PeopleMunicipalityCell = ({
municipalityIds,
municipalityIndex,
addableIds,
minItems,
readOnly = false,
commitAction,
drawerTitle,
@ -85,6 +82,23 @@ export const PeopleMunicipalityCell = ({
/** The pending destructive exit: the dialog is up, the commit waits for it. */
const [exitRequest, setExitRequest] = useState<PersonCapacityExitManifest | null>(null)
const exitResolverRef = useRef<((confirmed: boolean) => void) | null>(null)
/** A manifest round-trip in flight — a second guard must not orphan the first. */
const manifestPendingRef = useRef(false)
/** Stable identity for the FormData extra-field map (memoized per contact). */
const extraFormFields = useMemo(() => ({ contactId }), [contactId])
/**
* Unmounting with the dialog open must settle the pending guard (resolve
* `false` — nothing was confirmed, nothing commits) instead of leaving the
* promise orphaned and the gesture silently lost.
*/
useEffect(() => {
return () => {
exitResolverRef.current?.(false)
exitResolverRef.current = null
}
}, [])
const toggleExpand = useCallback((chipKey: string) => {
setExpandedKeys((current) => {
@ -126,10 +140,16 @@ export const PeopleMunicipalityCell = ({
* the optimistic apply: a removal that empties the relation pauses the
* commit and (for account/leadership) asks the manifest-backed dialog.
* Fail-closed: a manifest error aborts the commit — nothing is ever deleted
* without an explicit confirmation.
* without an explicit confirmation. Returns `'destructive'` when the dialog
* confirmed the exit, so the shared cell suppresses the batch-removal undo
* toast — its "Desfazer" would only re-create the destroyed entity empty.
*/
const commitGuard = useCallback(
async (delta: { changedIds: number[]; assigned: boolean; currentIds: number[] }) => {
async (delta: {
changedIds: number[]
assigned: boolean
currentIds: number[]
}): Promise<boolean | 'destructive'> => {
if (!exitMode || delta.assigned) return true
const remaining = delta.currentIds.filter((id) => !delta.changedIds.includes(id))
if (remaining.length > 0) return true
@ -138,9 +158,12 @@ export const PeopleMunicipalityCell = ({
// the cleanup is automatic (intention rabbit hole), no dialog.
if (exitMode === 'stateDeputy') return true
// A second guard while one dialog is already pending is refused: the
// pending commit must resolve first.
if (exitResolverRef.current) return false
// A second guard while a dialog is pending OR a manifest round-trip is
// in flight is refused: the pending commit must resolve first (two
// concurrent guards would overwrite each other's resolver and orphan
// the first commit silently).
if (exitResolverRef.current || manifestPendingRef.current) return false
manifestPendingRef.current = true
let manifest: PersonCapacityExitManifest | null
try {
@ -151,6 +174,8 @@ export const PeopleMunicipalityCell = ({
} catch (error) {
toast.error(mapManifestError(error))
return false
} finally {
manifestPendingRef.current = false
}
// Nothing to destroy (no entity) — the server no-ops the removal.
if (manifest === null) return true
@ -161,10 +186,11 @@ export const PeopleMunicipalityCell = ({
if (manifest.declaredVoteCount === 0 && manifest.inviteCount === 0) return true
}
return new Promise<boolean>((resolve) => {
const confirmed = await new Promise<boolean>((resolve) => {
exitResolverRef.current = resolve
setExitRequest(manifest)
})
return confirmed ? 'destructive' : false
},
[exitMode, contactId],
)
@ -183,7 +209,6 @@ export const PeopleMunicipalityCell = ({
municipalityIds={municipalityIds}
municipalityIndex={municipalityIndex}
addableIds={addableIds}
minItems={minItems}
commitAction={commitAction}
drawerTitle={drawerTitle}
updateErrorMessage={updateErrorMessage}
@ -192,7 +217,7 @@ export const PeopleMunicipalityCell = ({
onChipClick={toggleExpand}
overflowToggleLabel={(count) => `+${count}`}
readOnly={readOnly}
extraFormFields={{ contactId }}
extraFormFields={extraFormFields}
commitWithNullOwner
commitGuard={exitMode ? commitGuard : undefined}
/>

View file

@ -107,7 +107,7 @@ type MunicipalityPortfolioCellProps = {
changedIds: number[]
assigned: boolean
currentIds: number[]
}) => Promise<boolean>
}) => Promise<boolean | 'destructive'>
}
/**

View file

@ -200,14 +200,18 @@ type RelationChipCellProps = {
* C128 — destructive-exit guard: called on removals (and additions) BEFORE
* the optimistic apply, same point as the floor/cap refusals. Return `false`
* to abort the commit without touching the chip state (the wrapper opens its
* confirmation dialog). `currentIds` is the optimistic-aware effective set,
* so the wrapper can decide "does this removal empty the relation?".
* confirmation dialog), `true` to proceed, or `'destructive'` when the
* wrapper confirmed a destructive exit — the caller then suppresses the
* batch-removal undo toast, whose "Desfazer" would only re-create the
* destroyed entity empty (votes/account are gone). `currentIds` is the
* optimistic-aware effective set, so the wrapper can decide "does this
* removal empty the relation?".
*/
commitGuard?: (delta: {
changedIds: number[]
assigned: boolean
currentIds: number[]
}) => Promise<boolean>
}) => Promise<boolean | 'destructive'>
}
/**
@ -520,7 +524,7 @@ export const RelationChipCell = ({
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
* each compute from the set their own render captured and drop the other's work.
*/
const commit = (changedIds: number[], assigned: boolean) => {
const commit = (changedIds: number[], assigned: boolean, suppressUndoToast = false) => {
if (changedIds.length === 0) return
if (draft) {
@ -548,8 +552,11 @@ export const RelationChipCell = ({
setFeedback({ kind: 'saved' })
// A batch removal drops several links in one tap, and the row it left
// behind carries no trace of what was there. Single removals are one
// search away from being restored and get no toast.
if (!assigned && changedIds.length > 1) {
// search away from being restored and get no toast. C128: a
// destructive exit (the wrapper's guard confirmed the entity dies)
// suppresses the toast — "Desfazer" would only re-create the destroyed
// entity empty.
if (!assigned && changedIds.length > 1 && !suppressUndoToast) {
toast.success(copy.removedMessage(changedIds.length), {
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
})
@ -568,15 +575,19 @@ export const RelationChipCell = ({
/**
* C128 — the destructive-exit guard runs here, BEFORE the optimistic apply
* (same point as the floor/cap refusals): a `false` aborts with no state
* touched — the wrapper's confirmation dialog decides. The undo-toast path
* calls `commit` directly and never passes the guard (re-adding can never
* empty a relation).
* touched — the wrapper's confirmation dialog decides. A `'destructive'`
* verdict suppresses the batch-removal undo toast below (re-adding would
* only re-create the destroyed entity empty — votes/account are gone). The
* undo-toast path calls `commit` directly and never passes the guard
* (re-adding can never empty a relation).
*/
const guardedCommit = async (changedIds: number[], assigned: boolean) => {
if (commitGuard && !(await commitGuard({ changedIds, assigned, currentIds: effectiveIds }))) {
return
let verdict: boolean | 'destructive' = true
if (commitGuard) {
verdict = await commitGuard({ changedIds, assigned, currentIds: effectiveIds })
if (verdict === false) return
}
commit(changedIds, assigned)
commit(changedIds, assigned, verdict === 'destructive')
}
/** The relation's floor, refused here so the server never has to say no. */

View file

@ -448,40 +448,14 @@ export const deletePersonRecord = async (
})
}
if (accountIDs.length > 0) {
// Invites the deleted accounts CREATED for other leaderships, authored
// municipality updates and calendar feeds all carry NOT NULL FKs with
// `ON DELETE set null` — they must go before the accounts themselves.
// `deleteCampaignUserAccount` runs the same clean-up per account (the
// C128 "fim de assessoria" sibling); batch-then-loop here kept both
// paths honest by NOT hand-spelling the order twice.
await payload.delete({
collection: 'campaignInvite',
where: { createdBy: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'municipalityUpdate',
where: { author: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'calendarFeed',
where: { createdBy: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
await payload.delete({
collection: 'supporterImportBatch',
where: { actor: { in: accountIDs } },
depth: 0,
overrideAccess: true,
req,
})
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs
// for every account — bulk `where` deletes would skip the hooks. The
// helper also removes the NOT NULL authored rows (invites created by the
// account, municipality updates, calendar feeds, import batches) before
// the account row itself.
for (const accountID of accountIDs) {
await deleteCampaignUserAccount(payload, req, accountID)
}
}
if (supporterCount > 0) {
await payload.delete({
@ -522,12 +496,6 @@ export const deletePersonRecord = async (
}
}
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs for
// every account — bulk `where` deletes would skip the hooks.
for (const accountID of accountIDs) {
await deleteCampaignUserAccount(payload, req, accountID)
}
let contactDeleted = false
let contactAnonymized = false
if (signatureCount > 0 || subscriptionCount > 0) {

View file

@ -273,6 +273,54 @@ describe('C116 — cell edits of the people list', () => {
expect(result.accountId).toBeNull()
})
it('cascades authored rows when the account dies on the last municipality', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const only = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(only, [account])
const leadership = await fixtures.createLeadership({
contact: await fixtures.createContact(),
municipalities: [only],
})
await fixtures.createInvite({ leadership, createdBy: account })
await fixtures.createMunicipalityUpdate({ municipality: only, author: account })
await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [only.id],
assigned: false,
})
const remaining = await payload.find({
collection: 'campaignUser',
where: { id: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(remaining.docs).toHaveLength(0)
// The NOT NULL authored rows went down with the account (same order as
// the person cascade).
const invites = await payload.find({
collection: 'campaignInvite',
where: { createdBy: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(invites.docs).toHaveLength(0)
const updates = await payload.find({
collection: 'municipalityUpdate',
where: { author: { equals: account.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(updates.docs).toHaveLength(0)
})
it('refuses a person with more than one staff account', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
@ -474,6 +522,48 @@ describe('C116 — cell edits of the people list', () => {
})
expect(result.leadershipID).toBeNull()
})
it('re-creates the leadership when the undo toast re-adds after the exit', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: false,
})
const gone = await payload.find({
collection: 'leadership',
where: { id: { equals: leadership.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(gone.docs).toHaveLength(0)
// The undo path re-adds (`assigned=true`) — the lifecycle must bring the
// entity back (fresh, empty).
await setPersonLeadershipMunicipalitiesRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
})
const reborn = await payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(reborn.docs).toHaveLength(1)
expect(uniqueRelationshipIds(reborn.docs[0].municipalities)).toContain(municipality.id)
})
})
describe('setPersonStateDeputyMunicipalitiesRecord (C128 lifecycle)', () => {