simplify(B27): fix optimistic-state clobber, unify JSON route error shell, dedupe advisor id resolution

- MunicipalityListAdvisorsControl: stop advancing lastPropsIDsRef from a
  successful delta response — it exists only to detect a genuine external
  prop change, and writing it there made a later stale re-render of the
  pre-edit content look external and clobber optimistic state back to it.
  Also reconcile with the latest confirmed server set on the error path
  once every in-flight delta settles (previously only the success path did),
  and consolidate chip/tooltip name resolution onto the shared
  advisorEntriesFromIds helper instead of a bespoke lookup with an
  '#id' placeholder.
- Extract campaignJsonMutationErrorResponse/parseCampaignJsonRequestBody
  (src/utilities/campaignJsonMutationRoute.ts) and adopt them in both
  municipios/advisors/route.ts and municipios/expected-votes/route.ts,
  removing the duplicated body-parse + error-mapping ladder.
- Municipality.ts's validateMunicipalityAdvisors now uses
  uniqueRelationshipIds instead of re-inlining the Set+map+filter dance
  the helper was extracted for.

Verified: tsc --noEmit, lint, format:check, check:cycles all clean;
full unit suite (450 tests) and the municipality/advisor int suites pass.
knip's pre-existing payload.config.ts load error is unrelated (P3).
Playwright's spawned webServer failed to boot in this sandbox (unrelated
module-resolution environment issue, reproduced even before this diff);
manual pnpm dev boots fine and the change is covered by unit/int tests.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 03:51:32 -03:00
parent a4b62c1a9a
commit 2bbb630af6
5 changed files with 117 additions and 105 deletions

View file

@ -5,9 +5,9 @@ import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/acti
import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { uniqueRelationshipIds } from '@/utilities/relationship'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
@ -30,18 +30,11 @@ export async function POST(
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
let json: unknown
try {
json = await request.json()
} catch {
return NextResponse.json(
{ status: 'error', message: 'Corpo da requisição inválido.' },
{ status: 400 },
)
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, advisorId, assigned } = bodySchema.parse(json)
const { municipalityId, advisorId, assigned } = bodySchema.parse(parsed.body)
const updated = await setMunicipalityAdvisorMembership({
municipality: municipalityId,
advisor: advisorId,
@ -54,28 +47,10 @@ export async function POST(
advisors: uniqueRelationshipIds(updated.advisors),
})
} catch (error) {
const mapped = mapCampaignFormActionError({
error,
safeMessages: [
...MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES,
'Autenticação necessária.',
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
],
return campaignJsonMutationErrorResponse(error, {
safeMessages: MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES,
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
const isAuthError =
error instanceof Error &&
(error.message === 'Autenticação necessária.' ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
{
status: 'error',
message: mapped.message ?? 'Não foi possível atualizar os assessores.',
},
{ status: isAuthError ? 401 : 400 },
)
}
}

View file

@ -6,9 +6,9 @@ import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { MunicipalityListExpectedVotesResponse } from './types'
@ -36,18 +36,11 @@ export async function POST(
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
let json: unknown
try {
json = await request.json()
} catch {
return NextResponse.json(
{ status: 'error', message: 'Corpo da requisição inválido.' },
{ status: 400 },
)
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, expectedVotes } = bodySchema.parse(json)
const { municipalityId, expectedVotes } = bodySchema.parse(parsed.body)
const updated = await setMunicipalityExpectedVotes({
municipality: municipalityId,
expectedVotes,
@ -59,28 +52,10 @@ export async function POST(
savedExpectedVotes: toVoteEstimateScenarioViewModel(updated.expectedVotes),
})
} catch (error) {
const mapped = mapCampaignFormActionError({
error,
safeMessages: [
...municipalityStaffEditSafeMessages,
'Autenticação necessária.',
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
],
return campaignJsonMutationErrorResponse(error, {
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
const isAuthError =
error instanceof Error &&
(error.message === 'Autenticação necessária.' ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
{
status: 'error',
message: mapped.message ?? 'Não foi possível salvar os votos estimados.',
},
{ status: isAuthError ? 401 : 400 },
)
}
}

View file

@ -24,7 +24,7 @@ import {
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { politicalTrendLabels } from '@/utilities/municipalityLabels'
import { relationshipId } from '@/utilities/relationship'
import { relationshipId, uniqueRelationshipIds } from '@/utilities/relationship'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,
@ -81,9 +81,7 @@ const validateExpectedVotes: CollectionBeforeValidateHook = ({ data, originalDoc
const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data || data.advisors === undefined) return data
const advisorIDs = Array.isArray(data.advisors)
? [...new Set(data.advisors.map(relationshipId).filter((id): id is number => id !== null))]
: []
const advisorIDs = Array.isArray(data.advisors) ? uniqueRelationshipIds(data.advisors) : []
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({

View file

@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
@ -53,10 +54,15 @@ export const MunicipalityListAdvisorsControl = ({
const [isPending, setIsPending] = useState(false)
const pendingCountRef = useRef(0)
const lastPropsIDsRef = useRef(currentAdvisorIDs)
// Requests can settle out of send order (network jitter). Track the send
// order explicitly and only ever adopt the response with the highest
// sequence number seen so far — never "whichever response happens to be
// the one that brings the pending count to zero".
// Requests can settle out of send order (network jitter), so the send order
// is tracked explicitly: only the response with the highest sequence number
// seen so far is kept, and it is adopted — win or fail — once every
// in-flight delta has settled (`pendingCountRef` back to 0). No response
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
// external prop change (nav/refresh) apart from a no-op re-render carrying
// the same pre-edit content, and advancing it here would make a later
// stale re-render of that same pre-edit content look "external" and
// clobber the optimistic state back to it.
const requestSeqRef = useRef(0)
const latestConfirmedRef = useRef<{ seq: number; advisors: number[] } | null>(null)
@ -70,30 +76,29 @@ export const MunicipalityListAdvisorsControl = ({
}, [currentAdvisorIDs])
// Every eligible account (coordinator/advisor/candidate) is listed here
// regardless of current assignment, so it doubles as the name lookup for
// chips the popover just added — no round trip needed to label them.
const optionNameById = useMemo(
() => new Map(options.map((option) => [option.id, option.name])),
[options],
)
// regardless of current assignment, so it doubles as the name lookup for an
// id the popover just optimistically added — no round trip needed to label
// it. `advisorNamesById` is the fallback for an id that fell out of
// eligibility but is still assigned; `advisorEntriesFromIds` drops the rare
// id found in neither, same as every other advisor cell in the list.
const advisorLookup = useMemo(() => {
const lookup = new Map<number, { id: number; name: string }>(advisorNamesById)
for (const option of options) lookup.set(option.id, { id: option.id, name: option.name })
return lookup
}, [advisorNamesById, options])
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
const chips = useMemo(
() =>
selectedIDs
.map((id) => ({
id,
name: optionNameById.get(id) ?? advisorNamesById.get(id)?.name ?? `Assessor #${id}`,
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')),
[selectedIDs, optionNameById, advisorNamesById],
[...advisorEntriesFromIds(selectedIDs, advisorLookup)].sort((left, right) =>
left.name.localeCompare(right.name, 'pt-BR'),
),
[selectedIDs, advisorLookup],
)
// Redundant with the chips themselves, but B23's hover/focus tooltip on the
// trigger stays here so the coordinator sees the full list without opening
// the popover — `formatAdvisorNamesTooltip` returns `null` for an empty
// list (the trigger already reads "Sem responsável"/"Sem assessor" by
// extenso, so a tooltip would only echo it).
// B23's hover/focus tooltip stays on the trigger even though it repeats the
// chips: it's the no-open-popover read. `null` for an empty list — the
// trigger already reads "Sem responsável"/"Sem assessor" by extenso.
const tooltipContent = formatAdvisorNamesTooltip(chips)
const filteredOptions = useMemo(
@ -125,6 +130,18 @@ export const MunicipalityListAdvisorsControl = ({
})
}
// Single settle point for every exit path (success, mapped error,
// network failure): decrements the pending count and, once it reaches
// 0, stops the spinner and reconciles to the latest confirmed server
// set — even on this request's own failure, an earlier delta in the
// same batch may have already confirmed one.
const finishRequest = () => {
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
if (pendingCountRef.current > 0) return
setIsPending(false)
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.advisors)
}
try {
const response = await fetch(ADVISORS_ENDPOINT, {
method: 'POST',
@ -134,32 +151,22 @@ export const MunicipalityListAdvisorsControl = ({
})
const payload = (await response.json()) as MunicipalityListAdvisorsResponse
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
if (!response.ok || payload.status !== 'success') {
revertDelta()
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
finishRequest()
return
}
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors }
}
// Only adopt the server's confirmed set once every in-flight delta has
// settled, and always the most recently *sent* delta's response (not
// whichever response happens to be the one landing last — responses
// can arrive out of send order under network jitter).
if (pendingCountRef.current === 0 && latestConfirmedRef.current) {
lastPropsIDsRef.current = latestConfirmedRef.current.advisors
setSelectedIDs(latestConfirmedRef.current.advisors)
}
finishRequest()
} catch {
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
revertDelta()
setErrorMessage(SAVE_ERROR_MESSAGE)
} finally {
if (pendingCountRef.current === 0) setIsPending(false)
finishRequest()
}
})()
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
export type CampaignJsonErrorBody = { status: 'error'; message: string }
/**
* Shared shell for `POST /campanha/**` JSON mutation routes (list-popover
* auto-save): parses the body, mapping a malformed one to the same 400 both
* routes already used. Domain parsing/success payloads stay per-route.
*/
export const parseCampaignJsonRequestBody = async (
request: Request,
): Promise<
{ ok: true; body: unknown } | { ok: false; response: NextResponse<CampaignJsonErrorBody> }
> => {
try {
return { ok: true, body: await request.json() }
} catch {
return {
ok: false,
response: NextResponse.json(
{ status: 'error', message: 'Corpo da requisição inválido.' },
{ status: 400 },
),
}
}
}
/**
* Maps a thrown error to the JSON error response: safe messages (incl. the
* two auth ones, appended once here) pass through as 400, an expired/missing
* session is 401, anything else collapses to `genericMessage`.
*/
export const campaignJsonMutationErrorResponse = (
error: unknown,
{ safeMessages, genericMessage }: { safeMessages: readonly string[]; genericMessage: string },
): NextResponse<CampaignJsonErrorBody> => {
const mapped = mapCampaignFormActionError({
error,
safeMessages: [...safeMessages, 'Autenticação necessária.', CAMPAIGN_SESSION_EXPIRED_MESSAGE],
genericMessage,
})
const isAuthError =
error instanceof Error &&
(error.message === 'Autenticação necessária.' ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
{ status: 'error', message: mapped.message ?? genericMessage },
{ status: isAuthError ? 401 : 400 },
)
}