From 0a63621d4b50f210a041abac229daa4c2dba5b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 10:25:47 -0300 Subject: [PATCH] B37 simplify: one membership delta, cap messages named once. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextStateDeputyIdsAfterMunicipalityMembership was the third byte-for-byte copy of the same single-id delta — nextAdvisorIdsAfterMembership and nextStateDeputyIdsAfterMembership differ from it only in which cap they read — so the algorithm now lives once in nextIdsAfterMembership with the cap as data, and the three named wrappers just bind their own. The three cloned spec files became one describe.each suite: coverage went up rather than down, since every relation now gets the "can go to zero" and no-mutation cases only B37's clone had, plus an explicit assertion that each wrapper refuses at ITS cap with ITS message. That pairing matters because mapCampaignFormActionError matches thrown messages against safeMessages by exact string, and two of those cap messages were bare literals in three src places each (thrown, plus two independent allowlists): rewording any one would have silently collapsed a real refusal into "tente novamente". Both are constants now, next to their caps, following the MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE precedent already in those same files. The dobradinha staff message had the same exposure in four places — and actions/stateDeputy.ts already imported the constant while restating it as a literal one line below — so it is STATE_DEPUTY_STAFF_MESSAGE (renamed: it guards create/update too, not just the batch), and the conflict message it shares with the create route is named too. The batch action stopped resolving the dobradinha slug before its loop: the slug exists only to target the revalidate, which the caller already skips when nothing changed, so on a no-op batch that read was fetched and thrown away — the guard setLeadershipMunicipalitiesMembershipRecord already had. Its redundant second dedup is gone (the schema's transform already deduped; the sort stays, for deadlock-free lock ordering), and the bare 435 shared with the advisor batch is MAX_MUNICIPALITIES_PER_BATCH. Two memoization gaps closed, both restoring invariants the codebase had already established elsewhere. An advisor's scoped .filter() returned a fresh array per row, defeating the WeakMap that exists precisely to share derivations across rows, so scopedPortfolioIndex memoizes on (index, addableIds) and every row gets one array. And LeadershipStateDeputyRelationCell called matchesAtWordStart per candidate, paying an NFD pass plus three Unicode regexes on both sides per keystroke over the unpaginated leadership catalog — the cost wordStartFilter's own doc comment warns about; it now normalizes the query once against labels cached on options, like municipalityPortfolio. Rejected with reason: maxItems on the dobradinhas cell. A dobradinha has no product ceiling on municípios (that is why the bound is the catalog), so a "generous 30" would invent a rule that refuses a legitimate statewide dobradinha, and the honest 435 is unreachable when ids are unique. Also dropped the dead addPlaceholder knob no caller ever set. --- .../(app)/dobradinhas/[slug]/formActions.ts | 3 +- .../campanha/(app)/dobradinhas/formActions.ts | 4 +- .../(app)/dobradinhas/nova/formActions.ts | 9 ++- .../campanha/(app)/liderancas/formActions.ts | 4 +- .../campanha/actions/stateDeputy.ts | 54 ++++++------- .../LeadershipStateDeputyRelationCell.tsx | 45 +++++++++-- .../shared/MunicipalityPortfolioCell.tsx | 10 +-- .../campaign/shared/RelationChipCell.tsx | 6 +- src/lib/leadershipStateDeputyMembership.ts | 32 ++++---- src/lib/municipalityAdvisorMembership.ts | 25 +++---- src/lib/municipalityPortfolio.ts | 32 ++++++++ src/lib/municipalityStateDeputyMembership.ts | 26 +++---- src/lib/relationMembershipDelta.ts | 32 ++++++++ src/lib/schemas/advisor.ts | 9 ++- src/lib/schemas/leadership.ts | 3 + src/lib/schemas/municipality.ts | 12 ++- src/lib/schemas/stateDeputy.ts | 26 +++++-- ...adershipStateDeputyMembership.unit.spec.ts | 26 ------- ...municipalityAdvisorMembership.unit.spec.ts | 26 ------- ...cipalityStateDeputyMembership.unit.spec.ts | 33 -------- .../unit/relationMembershipDelta.unit.spec.ts | 75 +++++++++++++++++++ 21 files changed, 294 insertions(+), 198 deletions(-) create mode 100644 src/lib/relationMembershipDelta.ts delete mode 100644 tests/unit/leadershipStateDeputyMembership.unit.spec.ts delete mode 100644 tests/unit/municipalityAdvisorMembership.unit.spec.ts delete mode 100644 tests/unit/municipalityStateDeputyMembership.unit.spec.ts create mode 100644 tests/unit/relationMembershipDelta.unit.spec.ts diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts index af2bdd75..a32b198b 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache' import { updateStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy' import { nullableFormText, requiredRelationshipFormValue } from '@/lib/formData' +import { STATE_DEPUTY_STAFF_MESSAGE } from '@/lib/schemas/stateDeputy' import { runCampaignFormAction, type CampaignFormActionState, @@ -23,6 +24,6 @@ export const updateStateDeputyFormAction = async ( revalidatePath('/campanha/dobradinhas/[slug]', 'page') return { message: 'Dobradinha atualizada.' } }, - safeMessages: ['Somente a coordenação e a assessoria gerenciam dobradinhas.'], + safeMessages: [STATE_DEPUTY_STAFF_MESSAGE], genericMessage: 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index c14f43a2..35f1972e 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -7,7 +7,7 @@ import { requiredFormBoolean, requiredRelationshipFormValue, } from '@/lib/formData' -import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership' +import { LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE } from '@/lib/schemas/leadership' import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy' import { runCampaignFormAction, @@ -16,7 +16,7 @@ import { const safeMessages = [ 'Somente a coordenação e a assessoria podem gerenciar lideranças.', - `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`, + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, ] as const /** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */ diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts index 8949ca92..59f8558f 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/formActions.ts @@ -2,6 +2,10 @@ import { createStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy' import { optionalFormText, requiredFormText } from '@/lib/formData' +import { + STATE_DEPUTY_CONFLICT_MESSAGE, + STATE_DEPUTY_STAFF_MESSAGE, +} from '@/lib/schemas/stateDeputy' import { runCampaignRedirectFormAction, type CampaignFormActionState, @@ -19,10 +23,7 @@ export const createStateDeputyFormAction = async ( notes: optionalFormText(formData, 'notes'), }), redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.slug}`, - safeMessages: [ - 'Já existe uma dobradinha com este nome.', - 'Somente a coordenação e a assessoria gerenciam dobradinhas.', - ], + safeMessages: [STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_STAFF_MESSAGE], genericMessage: 'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index dce09b70..bb34357c 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -14,7 +14,7 @@ import { LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, - MAX_LEADERSHIP_STATE_DEPUTIES, + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, } from '@/lib/schemas/leadership' import { runCampaignFormAction, @@ -23,7 +23,7 @@ import { const safeMessages = [ ...leadershipStaffEditSafeMessages, - `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`, + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index c2ebd779..fddd2133 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -5,7 +5,8 @@ import type { Payload } from 'payload' import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership' import { - STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE, + STATE_DEPUTY_CONFLICT_MESSAGE, + STATE_DEPUTY_STAFF_MESSAGE, stateDeputyCreateSchema, stateDeputyMunicipalitiesBatchSchema, stateDeputyUpdateSchema, @@ -23,9 +24,9 @@ import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' import { uniqueRelationshipIds } from '@/utilities/relationship' const stateDeputyPolicy: StaffEntityPolicy = { - staffMessage: 'Somente a coordenação e a assessoria gerenciam dobradinhas.', + staffMessage: STATE_DEPUTY_STAFF_MESSAGE, conflictPattern: /state_deputy_(name|slug)|duplicate key/i, - conflictMessage: 'Já existe uma dobradinha com este nome.', + conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE, } const createStateDeputyRecord = async ( @@ -105,17 +106,14 @@ export const setStateDeputyMunicipalitiesBatchRecord = async ( ) => { const { stateDeputyId, municipalityIds, assigned } = stateDeputyMunicipalitiesBatchSchema.parse(input) - const uniqueMunicipalityIds = [...new Set(municipalityIds)].sort((left, right) => left - right) + // Already deduped by the schema's `.transform`; sorted only so the advisory + // locks below are always taken in the same order (deadlock avoidance). + const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right) return withPayloadTransaction( payload, async ({ req }) => { - const currentActor = await reloadStaffActor( - payload, - actor, - STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE, - req, - ) + const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req) await acquireTextAdvisoryLocks( payload, @@ -123,21 +121,6 @@ export const setStateDeputyMunicipalitiesBatchRecord = async ( uniqueMunicipalityIds.map((id) => `municipality-state-deputies:${id}`), ) - // Intentional admin bypass: only used to resolve the slug of the - // touched dobradinha for a targeted revalidate; existence is otherwise - // enforced by Payload's relationship validation on `update` (same - // precedent as `setLeadershipStateDeputyMembershipRecord`, B31). - const stateDeputySlug = ( - await payload.findByID({ - collection: 'stateDeputy', - id: stateDeputyId, - depth: 0, - select: { slug: true }, - overrideAccess: true, - req, - }) - ).slug - // Every município actually changed, not just the last one — same bug // class the B34 twin fixed: a território chip can touch up to 435 // municípios in one call, and revalidating only the last would leave @@ -178,6 +161,27 @@ export const setStateDeputyMunicipalitiesBatchRecord = async ( if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug) } + // Resolved AFTER the loop, and only when something changed: the slug + // exists solely to target the revalidate, which the caller skips on a + // no-op batch — so on a no-op this read was pure waste (the guard the + // `setLeadershipMunicipalitiesMembershipRecord` twin already had). + // Intentional admin bypass: existence is otherwise enforced by Payload's + // relationship validation on `update` (precedent: + // `setLeadershipStateDeputyMembershipRecord`, B31). + const stateDeputySlug = + changedSlugs.length > 0 + ? ( + await payload.findByID({ + collection: 'stateDeputy', + id: stateDeputyId, + depth: 0, + select: { slug: true }, + overrideAccess: true, + req, + }) + ).slug + : undefined + return { stateDeputySlug, slugs: changedSlugs } }, { beginFailureMessage: 'Não foi possível atualizar os municípios da dobradinha.' }, diff --git a/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx index 33999ccb..c0742c78 100644 --- a/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx +++ b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx @@ -8,7 +8,7 @@ import { type RelationChipCellCopy, type RelationSearchHit, } from '@/components/campaign/shared/RelationChipCell' -import { matchesAtWordStart } from '@/lib/wordStartFilter' +import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' export type RelationCellItem = { @@ -44,6 +44,25 @@ type LeadershipStateDeputyRelationCellProps = { const itemLabel = (item: RelationCellItem): string => item.party ? `${item.label} (${item.party})` : item.label +/** + * Normalized search labels, once per `options` array rather than per row: the + * RSC builds one array and every row of the table gets that same reference, so a + * `WeakMap` shares the work where a per-row `useMemo` would repeat it (same + * reasoning as `municipalityPortfolio`'s derivation cache). + */ +const normalizedLabelsByOptions = new WeakMap>() + +const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map => { + const cached = normalizedLabelsByOptions.get(options) + if (cached) return cached + + const normalized = new Map( + options.map((option) => [option.id, normalizeSearchPhrase(option.searchLabel)] as const), + ) + normalizedLabelsByOptions.set(options, normalized) + return normalized +} + const DIRECTION_COPY: Record< LeadershipStateDeputyRelationDirection, { @@ -127,12 +146,24 @@ export const LeadershipStateDeputyRelationCell = ({ ) const searchHits = useCallback( - (query: string, assignedIds: ReadonlySet): RelationSearchHit[] => - options - .filter( - (option) => !assignedIds.has(option.id) && matchesAtWordStart(option.searchLabel, query), - ) - .map((option) => ({ key: String(option.id), label: option.searchLabel, ids: [option.id] })), + (query: string, assignedIds: ReadonlySet): RelationSearchHit[] => { + // Normalize the query ONCE and read pre-normalized labels, instead of an + // NFD pass plus three Unicode-property regexes per candidate per + // keystroke — the cost `wordStartFilter`'s own doc comment warns about, + // and which matters here because `options` in the `fromStateDeputy` + // direction is the whole (unpaginated) leadership catalog. + const normalizedQuery = normalizeSearchPhrase(query) + const normalizedLabels = normalizedOptionLabels(options) + const hits: RelationSearchHit[] = [] + for (const option of options) { + if (assignedIds.has(option.id)) continue + if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) { + continue + } + hits.push({ key: String(option.id), label: option.searchLabel, ids: [option.id] }) + } + return hits + }, [options], ) diff --git a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx index 54784e8f..06060796 100644 --- a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx +++ b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx @@ -11,6 +11,7 @@ import { } from '@/components/campaign/shared/RelationChipCell' import { buildMunicipalityPortfolioChips, + scopedPortfolioIndex, searchMunicipalityPortfolio, type MunicipalityPortfolioChip, type MunicipalityPortfolioIndexEntry, @@ -113,13 +114,12 @@ export const MunicipalityPortfolioCell = ({ /** * Suggestions are scoped, chips are not: filtering the index the search reads * also shrinks the território / ZE hits to what the actor may actually add, - * instead of offering a batch the server would reject halfway. + * instead of offering a batch the server would reject halfway. Memoized by + * `(index, addableIds)` rather than per row so all rows share one array — see + * `scopedPortfolioIndex`. */ const searchIndex = useMemo( - () => - addableIds - ? municipalityIndex.filter((entry) => addableIds.has(entry.id)) - : municipalityIndex, + () => scopedPortfolioIndex(municipalityIndex, addableIds), [addableIds, municipalityIndex], ) diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index 793d3dd3..1abc7be9 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -73,8 +73,6 @@ export type RelationSearchHit = { export type RelationChipCellCopy = { searchPlaceholder: string - /** Shown once the cell already has chips. Defaults to "Adicionar…". */ - addPlaceholder?: string searchLabel: string suggestionsLabel: string emptyDrawerMessage: string @@ -679,9 +677,7 @@ export const RelationChipCell = ({ }, 120) }} onKeyDown={onSearchKeyDown} - placeholder={ - chips.length ? (copy.addPlaceholder ?? 'Adicionar…') : copy.searchPlaceholder - } + placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder} aria-label={copy.searchLabel} className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block" /> diff --git a/src/lib/leadershipStateDeputyMembership.ts b/src/lib/leadershipStateDeputyMembership.ts index d71e4de0..0aa06704 100644 --- a/src/lib/leadershipStateDeputyMembership.ts +++ b/src/lib/leadershipStateDeputyMembership.ts @@ -1,28 +1,22 @@ -import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership' +import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta' +import { + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, + MAX_LEADERSHIP_STATE_DEPUTIES, +} from '@/lib/schemas/leadership' /** * Returns the next state-deputy-id list after applying one membership * change, or `null` when the leadership is already in the desired state (no - * write needed). Mirrors `nextAdvisorIdsAfterMembership` — the pure delta - * calculator for the other side of a relation edited by chip+combobox - * (B31's `leadership.stateDeputies`, edited from the "Dobradinhas" column of - * `/campanha/liderancas`). + * write needed) — the `leadership.stateDeputies` side of the relation, edited + * from the "Dobradinhas" column of `/campanha/liderancas` (B31) and from the + * "Lideranças" column of `/campanha/dobradinhas` (B36). */ export const nextStateDeputyIdsAfterMembership = ( currentStateDeputyIDs: readonly number[], stateDeputyId: number, assigned: boolean, -): number[] | null => { - const alreadyAssigned = currentStateDeputyIDs.includes(stateDeputyId) - if (assigned === alreadyAssigned) return null - - if (assigned) { - if (currentStateDeputyIDs.length >= MAX_LEADERSHIP_STATE_DEPUTIES) { - throw new Error( - `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`, - ) - } - return [...currentStateDeputyIDs, stateDeputyId] - } - return currentStateDeputyIDs.filter((id) => id !== stateDeputyId) -} +): number[] | null => + nextIdsAfterMembership(currentStateDeputyIDs, stateDeputyId, assigned, { + max: MAX_LEADERSHIP_STATE_DEPUTIES, + message: LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, + }) diff --git a/src/lib/municipalityAdvisorMembership.ts b/src/lib/municipalityAdvisorMembership.ts index 327fa15c..bdefb119 100644 --- a/src/lib/municipalityAdvisorMembership.ts +++ b/src/lib/municipalityAdvisorMembership.ts @@ -1,4 +1,8 @@ -import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality' +import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta' +import { + MAX_ADVISORS_PER_MUNICIPALITY, + MUNICIPALITY_ADVISORS_CAP_MESSAGE, +} from '@/lib/schemas/municipality' /** * Returns the next advisor-id list after applying one membership change, or @@ -11,17 +15,8 @@ export const nextAdvisorIdsAfterMembership = ( currentAdvisorIDs: readonly number[], advisorId: number, assigned: boolean, -): number[] | null => { - const alreadyAssigned = currentAdvisorIDs.includes(advisorId) - if (assigned === alreadyAssigned) return null - - if (assigned) { - if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) { - throw new Error( - `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`, - ) - } - return [...currentAdvisorIDs, advisorId] - } - return currentAdvisorIDs.filter((id) => id !== advisorId) -} +): number[] | null => + nextIdsAfterMembership(currentAdvisorIDs, advisorId, assigned, { + max: MAX_ADVISORS_PER_MUNICIPALITY, + message: MUNICIPALITY_ADVISORS_CAP_MESSAGE, + }) diff --git a/src/lib/municipalityPortfolio.ts b/src/lib/municipalityPortfolio.ts index ee1b3e43..112c1459 100644 --- a/src/lib/municipalityPortfolio.ts +++ b/src/lib/municipalityPortfolio.ts @@ -125,6 +125,38 @@ const portfolioIndexDerivations = ( return derived } +/** + * The index an advisor's suggestions may draw from, as ONE array shared by every + * row — a per-row `.filter()` returns a distinct array identity even when the + * content is identical, which silently defeats the `WeakMap` above and makes each + * searching row rebuild the same O(435) derivation. Keyed on both inputs because + * the scope belongs to the actor, not to the index. + */ +const scopedIndexByAddableIds = new WeakMap< + ReadonlySet, + WeakMap +>() + +export const scopedPortfolioIndex = ( + index: readonly MunicipalityPortfolioIndexEntry[], + addableIds: ReadonlySet | undefined, +): readonly MunicipalityPortfolioIndexEntry[] => { + if (!addableIds) return index + + let byIndex = scopedIndexByAddableIds.get(addableIds) + if (!byIndex) { + byIndex = new WeakMap() + scopedIndexByAddableIds.set(addableIds, byIndex) + } + + const cached = byIndex.get(index) + if (cached) return cached + + const scoped = index.filter((entry) => addableIds.has(entry.id)) + byIndex.set(index, scoped) + return scoped +} + const municipalityIdsForTseZone = ( zoneNumber: number, bySlug: Map, diff --git a/src/lib/municipalityStateDeputyMembership.ts b/src/lib/municipalityStateDeputyMembership.ts index 7c1581f3..4b681688 100644 --- a/src/lib/municipalityStateDeputyMembership.ts +++ b/src/lib/municipalityStateDeputyMembership.ts @@ -1,3 +1,4 @@ +import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta' import { MAX_STATE_DEPUTIES_PER_MUNICIPALITY, MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE, @@ -6,25 +7,16 @@ import { /** * Returns the next state-deputy-id list for a município after applying one * membership change, or `null` when the município is already in the desired - * state (no write needed). Mirrors `nextAdvisorIdsAfterMembership` — the pure - * delta calculator for the other side of a relation edited by chip+batch - * (B37's `municipality.stateDeputies`, edited from the "Municípios" column of - * `/campanha/dobradinhas`, which writes the município side of the relation - * a `stateDeputy` chip in a batch touches). + * state (no write needed) — the município side of the relation that a + * `stateDeputy` chip batch touches (B37, from the "Municípios" column of + * `/campanha/dobradinhas`). */ export const nextStateDeputyIdsAfterMunicipalityMembership = ( currentStateDeputyIDs: readonly number[], stateDeputyId: number, assigned: boolean, -): number[] | null => { - const alreadyAssigned = currentStateDeputyIDs.includes(stateDeputyId) - if (assigned === alreadyAssigned) return null - - if (assigned) { - if (currentStateDeputyIDs.length >= MAX_STATE_DEPUTIES_PER_MUNICIPALITY) { - throw new Error(MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE) - } - return [...currentStateDeputyIDs, stateDeputyId] - } - return currentStateDeputyIDs.filter((id) => id !== stateDeputyId) -} +): number[] | null => + nextIdsAfterMembership(currentStateDeputyIDs, stateDeputyId, assigned, { + max: MAX_STATE_DEPUTIES_PER_MUNICIPALITY, + message: MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE, + }) diff --git a/src/lib/relationMembershipDelta.ts b/src/lib/relationMembershipDelta.ts new file mode 100644 index 00000000..96d90c1b --- /dev/null +++ b/src/lib/relationMembershipDelta.ts @@ -0,0 +1,32 @@ +/** + * Returns the next id list after applying ONE membership change to a bounded + * relation, or `null` when the owner is already in the desired state (no write + * needed) — the shape every chip-toggled relation in `/campanha` needs before + * it writes: `municipality.advisors` (B19/B27), `leadership.stateDeputies` + * (B31/B36) and `municipality.stateDeputies` (B37). + * + * The cap travels as data because that is the only thing that differs between + * them, and its `message` is a constant owned by the relation's schema rather + * than a literal: `mapCampaignFormActionError` matches thrown messages against + * `safeMessages` by exact string, so a reworded throw would silently collapse + * a real refusal into the generic "tente novamente". + * + * Not the batch sibling: `nextMunicipalityIdsAfterLeadershipMembership` moves a + * whole território at once and enforces a floor, so it reports `added`/`changed` + * instead of just the next list. + */ +export const nextIdsAfterMembership = ( + currentIDs: readonly number[], + id: number, + assigned: boolean, + cap: { max: number; message: string }, +): number[] | null => { + const alreadyAssigned = currentIDs.includes(id) + if (assigned === alreadyAssigned) return null + + if (assigned) { + if (currentIDs.length >= cap.max) throw new Error(cap.message) + return [...currentIDs, id] + } + return currentIDs.filter((currentId) => currentId !== id) +} diff --git a/src/lib/schemas/advisor.ts b/src/lib/schemas/advisor.ts index 5384b5cf..b60c9e2d 100644 --- a/src/lib/schemas/advisor.ts +++ b/src/lib/schemas/advisor.ts @@ -1,6 +1,9 @@ import { z } from 'zod' -import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality' +import { + MAX_MUNICIPALITIES_PER_BATCH, + MUNICIPALITY_ADVISORS_CAP_MESSAGE, +} from '@/lib/schemas/municipality' import { brazilianMobile, optionalPersistedEmail, @@ -41,7 +44,7 @@ export const ADVISOR_ACTION_SAFE_MESSAGES = [ PLACEHOLDER_RESET_MESSAGE, 'Só é possível gerenciar contas com papel de Assessor nesta tela.', 'Use Meu perfil para alterar a própria conta.', - `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`, + MUNICIPALITY_ADVISORS_CAP_MESSAGE, ] as const export const advisorCreateSchema = z.object({ @@ -64,7 +67,7 @@ export const advisorProfileUpdateSchema = z export const advisorMunicipalitiesBatchSchema = z.object({ advisorId: positiveRelationshipId, - municipalityIds: z.array(positiveRelationshipId).min(1).max(435), + municipalityIds: z.array(positiveRelationshipId).min(1).max(MAX_MUNICIPALITIES_PER_BATCH), assigned: z.boolean(), }) diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 6c63775b..5d6785ad 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -48,6 +48,9 @@ export const LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE = export const LEADERSHIP_MUNICIPALITY_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_MUNICIPALITIES} municípios.` +/** Thrown by the chip toggle on both sides of the relation (B31/B36), so both routes allowlist it. */ +export const LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.` + /** Thrown by the actions and matched verbatim by every route's `safeMessages`. */ export const LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE = 'Você só pode vincular lideranças aos municípios que assessora.' diff --git a/src/lib/schemas/municipality.ts b/src/lib/schemas/municipality.ts index bd772eeb..dcd68a2b 100644 --- a/src/lib/schemas/municipality.ts +++ b/src/lib/schemas/municipality.ts @@ -17,6 +17,14 @@ export type PoliticalTrendStatusValue = (typeof politicalTrendStatuses)[number] */ export const MAX_STATE_DEPUTIES_PER_MUNICIPALITY = 435 +/** + * Anti-payload bound on a batch that names many municípios at once (a whole + * território/ZE): the catalog is 435 rows and ids are unique, so this is the + * whole universe and not a product rule. Named because both município-side + * batch schemas (`advisor`, `stateDeputy`) were carrying a bare `435`. + */ +export const MAX_MUNICIPALITIES_PER_BATCH = 435 + export const MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_STATE_DEPUTIES_PER_MUNICIPALITY} dobradinhas.` const stateDeputiesArraySchema = z @@ -52,6 +60,8 @@ export const parsePoliticalTrendStatusFormValue = ( export const MAX_ADVISORS_PER_MUNICIPALITY = 10 +export const MUNICIPALITY_ADVISORS_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.` + export const municipalityAdvisorsAssignmentSchema = z.object({ municipality: positiveRelationshipId, advisors: z @@ -72,7 +82,7 @@ export const MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE = export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [ MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE, - `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`, + MUNICIPALITY_ADVISORS_CAP_MESSAGE, ] as const export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE = diff --git a/src/lib/schemas/stateDeputy.ts b/src/lib/schemas/stateDeputy.ts index d3f3cc26..88abb796 100644 --- a/src/lib/schemas/stateDeputy.ts +++ b/src/lib/schemas/stateDeputy.ts @@ -1,6 +1,9 @@ import { z } from 'zod' -import { MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE } from '@/lib/schemas/municipality' +import { + MAX_MUNICIPALITIES_PER_BATCH, + MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE, +} from '@/lib/schemas/municipality' import { positiveRelationshipId, trimmedNullableText, @@ -19,29 +22,38 @@ export const stateDeputyUpdateSchema = z.object({ notes: trimmedNullableText(4000), }) -export const STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE = +/** + * Refused staff-only write on any dobradinha — thrown by the create/update + * policy AND by the município batch. Named once because every route's + * `safeMessages` matches it by exact string: a reworded literal in one of the + * four places it used to live would have silently become the generic error. + */ +export const STATE_DEPUTY_STAFF_MESSAGE = 'Somente a coordenação e a assessoria gerenciam dobradinhas.' +/** Same exact-string contract as above, shared by the policy and the create route. */ +export const STATE_DEPUTY_CONFLICT_MESSAGE = 'Já existe uma dobradinha com este nome.' + /** * Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) — * adds or removes a set of municipalities (one chip, or a whole território/ZE) * on `municipality.stateDeputies` — the inverted side of the relation this - * `stateDeputy` sits on. `municipalityIds.max(435)` is the universe of - * municípios in one batch call (a whole território), not the per-município - * cap on dobradinhas, which lives in `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`. + * `stateDeputy` sits on. The bound is the universe of municípios in one batch + * call (a whole território), not the per-município cap on dobradinhas, which + * lives in `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`. */ export const stateDeputyMunicipalitiesBatchSchema = z.object({ stateDeputyId: positiveRelationshipId, municipalityIds: z .array(positiveRelationshipId) .min(1) - .max(435) + .max(MAX_MUNICIPALITIES_PER_BATCH) .transform((ids) => [...new Set(ids)]), assigned: z.boolean(), }) export const STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES = [ - STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE, + STATE_DEPUTY_STAFF_MESSAGE, MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE, ] as const diff --git a/tests/unit/leadershipStateDeputyMembership.unit.spec.ts b/tests/unit/leadershipStateDeputyMembership.unit.spec.ts deleted file mode 100644 index b9248710..00000000 --- a/tests/unit/leadershipStateDeputyMembership.unit.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership' -import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership' - -describe('nextStateDeputyIdsAfterMembership', () => { - it('adds a state deputy not yet present', () => { - expect(nextStateDeputyIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3]) - }) - - it('removes a state deputy present in the list', () => { - expect(nextStateDeputyIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3]) - }) - - it('is a no-op (returns null) when already in the desired state', () => { - expect(nextStateDeputyIdsAfterMembership([1, 2], 2, true)).toBeNull() - expect(nextStateDeputyIdsAfterMembership([1, 2], 3, false)).toBeNull() - }) - - it('throws once the leadership is at the state-deputy cap', () => { - const atCap = Array.from({ length: MAX_LEADERSHIP_STATE_DEPUTIES }, (_, index) => index + 1) - expect(() => nextStateDeputyIdsAfterMembership(atCap, 999, true)).toThrow( - `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`, - ) - }) -}) diff --git a/tests/unit/municipalityAdvisorMembership.unit.spec.ts b/tests/unit/municipalityAdvisorMembership.unit.spec.ts deleted file mode 100644 index 9b50b7a5..00000000 --- a/tests/unit/municipalityAdvisorMembership.unit.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership' -import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality' - -describe('nextAdvisorIdsAfterMembership', () => { - it('adds an advisor not yet present', () => { - expect(nextAdvisorIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3]) - }) - - it('removes an advisor present in the list', () => { - expect(nextAdvisorIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3]) - }) - - it('is a no-op (returns null) when already in the desired state', () => { - expect(nextAdvisorIdsAfterMembership([1, 2], 2, true)).toBeNull() - expect(nextAdvisorIdsAfterMembership([1, 2], 3, false)).toBeNull() - }) - - it('throws once the municipality is at the advisor cap', () => { - const atCap = Array.from({ length: MAX_ADVISORS_PER_MUNICIPALITY }, (_, index) => index + 1) - expect(() => nextAdvisorIdsAfterMembership(atCap, 999, true)).toThrow( - `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`, - ) - }) -}) diff --git a/tests/unit/municipalityStateDeputyMembership.unit.spec.ts b/tests/unit/municipalityStateDeputyMembership.unit.spec.ts deleted file mode 100644 index 5816f938..00000000 --- a/tests/unit/municipalityStateDeputyMembership.unit.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership' -import { MAX_STATE_DEPUTIES_PER_MUNICIPALITY } from '@/lib/schemas/municipality' - -describe('nextStateDeputyIdsAfterMunicipalityMembership', () => { - it('adds a state deputy not yet present', () => { - expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2], 3, true)).toEqual([1, 2, 3]) - }) - - it('removes a state deputy present in the list', () => { - expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2, 3], 2, false)).toEqual([1, 3]) - }) - - it('is a no-op (returns null) when already in the desired state', () => { - expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2], 2, true)).toBeNull() - expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2], 3, false)).toBeNull() - }) - - it('can go to zero', () => { - expect(nextStateDeputyIdsAfterMunicipalityMembership([1], 1, false)).toEqual([]) - }) - - it('throws once the municipality is at the state-deputy cap', () => { - const atCap = Array.from( - { length: MAX_STATE_DEPUTIES_PER_MUNICIPALITY }, - (_, index) => index + 1, - ) - expect(() => nextStateDeputyIdsAfterMunicipalityMembership(atCap, 999, true)).toThrow( - `Cada município aceita no máximo ${MAX_STATE_DEPUTIES_PER_MUNICIPALITY} dobradinhas.`, - ) - }) -}) diff --git a/tests/unit/relationMembershipDelta.unit.spec.ts b/tests/unit/relationMembershipDelta.unit.spec.ts new file mode 100644 index 00000000..9735895b --- /dev/null +++ b/tests/unit/relationMembershipDelta.unit.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership' +import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership' +import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership' +import { + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, + MAX_LEADERSHIP_STATE_DEPUTIES, +} from '@/lib/schemas/leadership' +import { + MAX_ADVISORS_PER_MUNICIPALITY, + MAX_STATE_DEPUTIES_PER_MUNICIPALITY, + MUNICIPALITY_ADVISORS_CAP_MESSAGE, + MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE, +} from '@/lib/schemas/municipality' + +/** + * One contract, three relations. Each wrapper only binds its own cap to + * `nextIdsAfterMembership`, so the delta behaviour is pinned once and each + * wrapper is additionally pinned to refuse at ITS cap with ITS message — the + * pair `safeMessages` matches by exact string. + */ +const relations = [ + { + name: 'nextAdvisorIdsAfterMembership (municipality.advisors)', + next: nextAdvisorIdsAfterMembership, + max: MAX_ADVISORS_PER_MUNICIPALITY, + capMessage: MUNICIPALITY_ADVISORS_CAP_MESSAGE, + }, + { + name: 'nextStateDeputyIdsAfterMembership (leadership.stateDeputies)', + next: nextStateDeputyIdsAfterMembership, + max: MAX_LEADERSHIP_STATE_DEPUTIES, + capMessage: LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, + }, + { + name: 'nextStateDeputyIdsAfterMunicipalityMembership (municipality.stateDeputies)', + next: nextStateDeputyIdsAfterMunicipalityMembership, + max: MAX_STATE_DEPUTIES_PER_MUNICIPALITY, + capMessage: MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE, + }, +] as const + +describe.each(relations)('$name', ({ next, max, capMessage }) => { + it('adds an id not yet present', () => { + expect(next([1, 2], 3, true)).toEqual([1, 2, 3]) + }) + + it('removes an id present in the list', () => { + expect(next([1, 2, 3], 2, false)).toEqual([1, 3]) + }) + + it('is a no-op (returns null) when already in the desired state', () => { + expect(next([1, 2], 2, true)).toBeNull() + expect(next([1, 2], 3, false)).toBeNull() + }) + + it('can go to zero', () => { + expect(next([1], 1, false)).toEqual([]) + }) + + it('does not mutate the list it was given', () => { + const current = [1, 2] + next(current, 3, true) + next(current, 2, false) + expect(current).toEqual([1, 2]) + }) + + it('throws its own cap message once at its own cap', () => { + const atCap = Array.from({ length: max }, (_, index) => index + 1) + expect(() => next(atCap, max + 1, true)).toThrow(capMessage) + // At the cap, a removal is still allowed — the ceiling is not a freeze. + expect(next(atCap, 1, false)).toHaveLength(max - 1) + }) +})