mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
a4b62c1a9a
commit
2bbb630af6
5 changed files with 117 additions and 105 deletions
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
|
|
|||
57
src/utilities/campaignJsonMutationRoute.ts
Normal file
57
src/utilities/campaignJsonMutationRoute.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue