diff --git a/src/collections/Leadership.ts b/src/collections/Leadership.ts index 0f24c0c6..47550c33 100644 --- a/src/collections/Leadership.ts +++ b/src/collections/Leadership.ts @@ -10,10 +10,9 @@ import { canReadLeadership, canSetAdministrativeLeadershipField, } from '@/utilities/campaignAccess' +import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership' import { relationshipId } from '@/utilities/relationship' -const MAX_LEADERSHIP_MUNICIPALITIES = 30 - const requireAtLeastOneMunicipality: CollectionBeforeValidateHook = ({ data, operation, diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index 236a7e91..f6b8eb7e 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -17,6 +17,8 @@ import { canUpdateMunicipality, eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' +import { politicalTrendStatuses } from '@/lib/schemas/municipality' +import { politicalTrendLabels } from '@/utilities/municipalityLabels' import { relationshipId } from '@/utilities/relationship' import { getVoteEstimateOrderViolation, @@ -327,11 +329,11 @@ export const Municipality: CollectionConfig = { name: 'status', type: 'select', label: 'Tendência', - options: [ - { label: 'Favorável', value: 'favoravel' }, - { label: 'Neutra', value: 'neutra' }, - { label: 'Desfavorável', value: 'desfavoravel' }, - ], + // Single source: the zod enum + the shared label table. + options: politicalTrendStatuses.map((value) => ({ + label: politicalTrendLabels[value], + value, + })), }, { name: 'note', diff --git a/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx b/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx index 1f5a1909..a7dc97c9 100644 --- a/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx +++ b/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx @@ -2,16 +2,9 @@ import { CircleAlertIcon } from 'lucide-react' import { Avatar, AvatarFallback } from '@/components/ui/Avatar' import { Badge } from '@/components/ui/Badge' +import { campaignUserInitials } from '@/utilities/campaignUserProfile' import { municipalityListCoverageLabels } from '@/utilities/municipalityLabels' -export const campaignUserInitials = (name: string): string => - name - .split(/\s+/) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase() - export type MunicipalityAdvisorAvatarEntry = { id: number name: string diff --git a/src/components/campaign/municipality/MunicipalityStrategyCard.tsx b/src/components/campaign/municipality/MunicipalityStrategyCard.tsx index de49134c..19ff37fc 100644 --- a/src/components/campaign/municipality/MunicipalityStrategyCard.tsx +++ b/src/components/campaign/municipality/MunicipalityStrategyCard.tsx @@ -9,7 +9,7 @@ import { politicalTrendBadgeVariant, politicalTrendLabels, } from '@/utilities/municipalityLabels' -import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels' +import type { MunicipalityStrategyViewModel } from '@/utilities/municipalityViewModels' const dateFormatter = new Intl.DateTimeFormat('pt-BR') @@ -18,7 +18,7 @@ export const MunicipalityStrategyCard = ({ municipalitySlug, canEdit, }: { - strategy: NonNullable + strategy: MunicipalityStrategyViewModel municipalitySlug: string canEdit: boolean }) => { diff --git a/src/components/campaign/municipality/MunicipalityStrategyForm.tsx b/src/components/campaign/municipality/MunicipalityStrategyForm.tsx index 0781a2e2..40337d52 100644 --- a/src/components/campaign/municipality/MunicipalityStrategyForm.tsx +++ b/src/components/campaign/municipality/MunicipalityStrategyForm.tsx @@ -14,7 +14,7 @@ import { Textarea } from '@/components/ui/textarea' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { fieldError } from '@/utilities/campaignFormFields' import { politicalTrendLabels } from '@/utilities/municipalityLabels' -import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels' +import type { MunicipalityStrategyViewModel } from '@/utilities/municipalityViewModels' type FormAction = ( state: CampaignFormActionState, @@ -24,7 +24,7 @@ type FormAction = ( type MunicipalityStrategyFormProps = { municipalityID: number municipalitySlug: string - strategy: NonNullable + strategy: MunicipalityStrategyViewModel stateDeputyOptions: RelationOption[] strategyFormAction: FormAction expectedVotesFormAction: FormAction diff --git a/src/lib/projectionSheetParse.ts b/src/lib/projectionSheetParse.ts index 1c14f29e..f7c913a5 100644 --- a/src/lib/projectionSheetParse.ts +++ b/src/lib/projectionSheetParse.ts @@ -6,6 +6,7 @@ * Bom → optimistic, Regular → central (média), Mínimo → pessimistic. */ +import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality' import type { VoteEstimateScenarioFields } from '@/lib/voteEstimate' /** Parsed sheet triple — all three scenarios present (same keys as expectedVotes). */ @@ -65,7 +66,7 @@ export const mapSheetPriority = (raw: string | null | undefined): ProjectionPrio } /** Municipality politicalTrend.status values derivable from the sheet SITUAÇÃO column. */ -export type ProjectionTrendStatus = 'favoravel' | 'neutra' | 'desfavoravel' +export type ProjectionTrendStatus = PoliticalTrendStatusValue const stripDiacritics = (value: string): string => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '') diff --git a/src/utilities/access/shared.ts b/src/utilities/access/shared.ts index 2e57e10d..e068557f 100644 --- a/src/utilities/access/shared.ts +++ b/src/utilities/access/shared.ts @@ -14,6 +14,13 @@ const FRESH_CAMPAIGN_USER_CONTEXT_KEY = 'campaignFreshUser' /** Enough of a request for Local API calls inside an existing transaction. */ export type CampaignTransactionRequest = PayloadRequest | { transactionID: number | string } +/** + * Narrow `payload.find` signature for helpers that query a collection chosen + * at runtime (Payload's generic can't be proven then). Call sites use the + * sanctioned `payload.find as unknown as DynamicFind` cast — the ONE approved + * spelling of that cast; keep the queried fields inside this arg shape so the + * casts stay honest. + */ export type DynamicFind = (args: { collection: string depth: number diff --git a/src/utilities/municipalityMapData.ts b/src/utilities/municipalityMapData.ts index 2657a687..80c506f1 100644 --- a/src/utilities/municipalityMapData.ts +++ b/src/utilities/municipalityMapData.ts @@ -30,6 +30,7 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO, VOTE_ESTIMATE_SCENARIOS, type VoteEstimateScenario, + type VoteEstimateScenarioFields, } from '@/lib/voteEstimate' import { emptyMunicipalityPledgeAggregate, resolveMunicipalityStaffVoteTotal, type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' @@ -50,11 +51,7 @@ export type ScopedMunicipality = { name: string kind: 'municipio' | 'zona' ibgeCode: string - expectedVotes?: { - pessimistic?: number | null - central?: number | null - optimistic?: number | null - } | null + expectedVotes?: VoteEstimateScenarioFields | null geography: MunicipalityElectionGeography } @@ -64,11 +61,7 @@ type ScopedMunicipalityDoc = { name: string kind: 'municipio' | 'zona' ibgeCode: string - expectedVotes?: { - pessimistic?: number | null - central?: number | null - optimistic?: number | null - } | null + expectedVotes?: VoteEstimateScenarioFields | null } export const scopeMunicipalitiesFromDocs = (docs: ReadonlyArray): ScopedMunicipality[] => diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index 84dd7f85..47c0d3d5 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -119,18 +119,25 @@ export type MunicipalityDetailViewModel = { advisorIDs: number[] lastUpdateAt: string | null /** Staff-only block — null for the leader view model. */ - strategy: { - priority: 'alta' | 'normal' - expectedVotes: VoteEstimateScenarioViewModel - politicalTrend: MunicipalityPoliticalTrendViewModel - strengths: string[] - risks: string[] - stateDeputyIDs: number[] - stateDeputies: StateDeputySummary[] - dobradinhaNotes: string | null - nextSteps: string | null - budgetNotes: string | null - } | null + strategy: MunicipalityStrategyViewModel | null +} + +/** + * The staff strategy block as the UI consumes it. Deliberately NOT derived + * from the zod input schema or payload-types: storage keeps `{ text }` rows + * and nullable scenario fields, the view flattens both. + */ +export type MunicipalityStrategyViewModel = { + priority: 'alta' | 'normal' + expectedVotes: VoteEstimateScenarioViewModel + politicalTrend: MunicipalityPoliticalTrendViewModel + strengths: string[] + risks: string[] + stateDeputyIDs: number[] + stateDeputies: StateDeputySummary[] + dobradinhaNotes: string | null + nextSteps: string | null + budgetNotes: string | null } export const toMunicipalityDetailViewModel = ( diff --git a/tests/int/campaignAuth.int.spec.ts b/tests/int/campaignAuth.int.spec.ts index fe513eb3..d4b952c2 100644 --- a/tests/int/campaignAuth.int.spec.ts +++ b/tests/int/campaignAuth.int.spec.ts @@ -435,7 +435,7 @@ describe('campaign authentication foundation', () => { const otherMunicipality = await campaignFixtures().getMunicipality() await campaignFixtures().assignMunicipalityAdvisors(assignedMunicipality, [advisor]) - const req = { context: {}, payload } as unknown as PayloadRequest + const req = stub({ context: {}, payload }) const ids = await getAccessibleMunicipalityIds(req, advisor) diff --git a/tests/int/campaignUserPhone.int.spec.ts b/tests/int/campaignUserPhone.int.spec.ts index 767b1e64..f8838fc1 100644 --- a/tests/int/campaignUserPhone.int.spec.ts +++ b/tests/int/campaignUserPhone.int.spec.ts @@ -231,16 +231,16 @@ describe('campaign user contact phone', () => { const coordinator = await fixtures.createCampaignUser('coordinator') const field = phoneField() if (field.type !== 'text') throw new Error('Campo phone deve ser text.') - const ownerReq = { + const ownerReq = stub({ context: {}, payload, user: owner, - } as unknown as PayloadRequest - const coordinatorReq = { + }) + const coordinatorReq = stub({ context: {}, payload, user: coordinator, - } as unknown as PayloadRequest + }) await expect( field.access?.update?.(stub({ id: owner.id, req: ownerReq })), diff --git a/tests/unit/campaignConsent.unit.spec.ts b/tests/unit/campaignConsent.unit.spec.ts index 9f41f072..d40cab84 100644 --- a/tests/unit/campaignConsent.unit.spec.ts +++ b/tests/unit/campaignConsent.unit.spec.ts @@ -12,13 +12,15 @@ import { } from '@/utilities/campaignConsent' import { CAMPAIGN_INVITE_CONSENT_KEY } from '@/utilities/campaignInvite' +import { stub } from '../helpers/stub' + describe('campaign consent descriptor', () => { it('returns the stable descriptor and propagates the transaction request', async () => { const req = { transactionID: 17 } const find = vi.fn().mockResolvedValue({ docs: [{ id: 9, text: { root: { children: [] } } }], }) - const payload = { find } as unknown as Payload + const payload = stub({ find }) const consent = await getLeadershipConsent(payload, req) @@ -42,7 +44,7 @@ describe('campaign consent descriptor', () => { docs: [{ id: 3, text: { root: { children: [] } }, key: SUPPORTER_REGISTRATION_CONSENT_KEY }], }) .mockResolvedValue({ docs: [] }) - const payload = { find } as unknown as Payload + const payload = stub({ find }) await expect(getConsentByKey(payload, SUPPORTER_REGISTRATION_CONSENT_KEY)).resolves.toEqual({ id: 3, @@ -57,9 +59,9 @@ describe('campaign consent descriptor', () => { }) it('keeps nullable and required missing-consent semantics distinct', async () => { - const payload = { + const payload = stub({ find: vi.fn().mockResolvedValue({ docs: [] }), - } as unknown as Payload + }) await expect(getLeadershipConsent(payload)).resolves.toBeNull() await expect(requireLeadershipConsent(payload, undefined, 'Consentimento ausente.')).rejects.toThrow(