mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
B37 simplify: one membership delta, cap messages named once.
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.
This commit is contained in:
parent
da4ca2c713
commit
0a63621d4b
21 changed files with 294 additions and 198 deletions
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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<readonly RelationCellOption[], Map<number, string>>()
|
||||
|
||||
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
|
||||
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<number>): 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<number>): 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],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<number>,
|
||||
WeakMap<readonly MunicipalityPortfolioIndexEntry[], readonly MunicipalityPortfolioIndexEntry[]>
|
||||
>()
|
||||
|
||||
export const scopedPortfolioIndex = (
|
||||
index: readonly MunicipalityPortfolioIndexEntry[],
|
||||
addableIds: ReadonlySet<number> | 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<string, MunicipalityPortfolioIndexEntry>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
32
src/lib/relationMembershipDelta.ts
Normal file
32
src/lib/relationMembershipDelta.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
75
tests/unit/relationMembershipDelta.unit.spec.ts
Normal file
75
tests/unit/relationMembershipDelta.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue