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) + }) +})