mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
0c1127b35a
commit
11d3fcd24c
12 changed files with 55 additions and 51 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, '')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] =>
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue