diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts index bb824c40..2900911f 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts @@ -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 }, - ) } } diff --git a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts index 5884b756..5ca33cd2 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts @@ -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 }, - ) } } diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index 5653a9d1..0c5c3366 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -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({ diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index 52b3d50a..2f65cd0a 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -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(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() } })() } diff --git a/src/utilities/campaignJsonMutationRoute.ts b/src/utilities/campaignJsonMutationRoute.ts new file mode 100644 index 00000000..7f6cfc85 --- /dev/null +++ b/src/utilities/campaignJsonMutationRoute.ts @@ -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 } +> => { + 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 => { + 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 }, + ) +}