Pass 2 W4e: tipos de fonte unica

- triplo de cenarios: municipalityMapData usa VoteEstimateScenarioFields (2 grafias inline eliminadas)
- MAX_LEADERSHIP_MUNICIPALITIES: Leadership.ts importa de lib/schemas/leadership (const local removida)
- campaignUserInitials: copia divergente do AvatarStack removida; fonte unica em campaignUserProfile (com fallback defensivo)
- tendencia politica: options do select da collection derivam de politicalTrendStatuses + politicalTrendLabels; ProjectionTrendStatus vira alias de PoliticalTrendStatusValue
- MunicipalityStrategyViewModel nomeado e exportado (consumidores param de indexar NonNullable<...['strategy']>); nota de por que NAO deriva do zod
- DynamicFind documentado como a unica grafia aprovada do cast
- stub<T> nos doubles de teste onde a checagem por propriedade e real (campaignUserPhone, campaignAuth, campaignConsent); deep fakes mantem o cast localizado
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 15:05:03 -03:00
parent 0c1127b35a
commit 11d3fcd24c
12 changed files with 55 additions and 51 deletions

View file

@ -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,

View file

@ -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',

View file

@ -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

View file

@ -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<MunicipalityDetailViewModel['strategy']>
strategy: MunicipalityStrategyViewModel
municipalitySlug: string
canEdit: boolean
}) => {

View file

@ -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<MunicipalityDetailViewModel['strategy']>
strategy: MunicipalityStrategyViewModel
stateDeputyOptions: RelationOption[]
strategyFormAction: FormAction
expectedVotesFormAction: FormAction

View file

@ -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, '')

View file

@ -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

View file

@ -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<ScopedMunicipalityDoc>): ScopedMunicipality[] =>

View file

@ -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 = (

View file

@ -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<PayloadRequest>({ context: {}, payload })
const ids = await getAccessibleMunicipalityIds(req, advisor)

View file

@ -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<PayloadRequest>({
context: {},
payload,
user: owner,
} as unknown as PayloadRequest
const coordinatorReq = {
})
const coordinatorReq = stub<PayloadRequest>({
context: {},
payload,
user: coordinator,
} as unknown as PayloadRequest
})
await expect(
field.access?.update?.(stub<FieldAccessArgs>({ id: owner.id, req: ownerReq })),

View file

@ -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<Payload>({ 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<Payload>({ 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<Payload>({
find: vi.fn().mockResolvedValue({ docs: [] }),
} as unknown as Payload
})
await expect(getLeadershipConsent(payload)).resolves.toBeNull()
await expect(requireLeadershipConsent(payload, undefined, 'Consentimento ausente.')).rejects.toThrow(