From cf6ae6c47cd1ab747dcfdfbfe14ea2a59ee5bcad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 14:38:41 -0300 Subject: [PATCH] Pass 2 W2: correcoes de fronteira lib/utilities e client boundary - helpers puros movidos utilities->lib: slug, phone, wordStartFilter, voteEstimate (9 inversoes lib->utilities zeradas) - chaves de consent em src/lib/campaignConsentKeys.ts (client-safe); campaignInvite/campaignConsent re-exportam; onda0ConsentTexts referencia as constantes - 'server-only' em 21 loaders Payload/cache (CLI segue com --conditions=react-server) - ciclo de tipos supporterListFilters <-> supporterUi quebrado (SupporterListFilterInput estrutural; sqlFilters idem) - votePledgeData dividido: votePledgeViews (puro, client-safe) + votePledgeData (loaders, server-only) - padrao contract-module - predicados de role client-safe em src/lib/campaignRoles.ts; access/shared deriva deles (fonte unica); nav.ts para de importar o barrel campaignAccess (casts 'as CampaignUser' eliminados) --- .../(app)/apoiadores/novo/formActions.ts | 2 +- .../campanha/(app)/assessores/[id]/page.tsx | 2 +- .../campanha/(app)/municipios/[slug]/page.tsx | 11 +- .../(app)/municipios/expected-votes/route.ts | 2 +- .../(app)/municipios/expected-votes/types.ts | 2 +- .../campanha/(app)/planos/formActions.ts | 2 +- .../campanha/actions/leaderSupporter.ts | 2 +- .../campanha/actions/municipality.ts | 2 +- .../(campaign)/campanha/actions/password.ts | 2 +- .../campanha/actions/supporterImport.ts | 2 +- .../(campaign)/campanha/actions/votePledge.ts | 2 +- src/collections/ActionPlan.ts | 2 +- src/collections/CampaignDemand.ts | 2 +- src/collections/CampaignUser.ts | 2 +- src/collections/Contact.ts | 2 +- src/collections/Municipality.ts | 2 +- src/collections/Organization.ts | 2 +- src/collections/Post.ts | 2 +- src/collections/StateDeputy.ts | 2 +- src/collections/Tag.ts | 2 +- src/collections/VotePledge.ts | 2 +- src/components/PhoneInput.tsx | 2 +- .../campaign/AdvisorDebouncedTextCell.tsx | 2 +- src/components/campaign/AdvisorsTable.tsx | 2 +- src/components/campaign/CampaignDashboard.tsx | 2 +- .../campaign/CampaignProfileSettings.tsx | 2 +- src/components/campaign/LeaderContactForm.tsx | 2 +- .../campaign/LeaderContactsPanel.tsx | 2 +- .../MunicipalityEstimateScenarioContext.tsx | 2 +- .../campaign/MunicipalityGoalAccountCard.tsx | 4 +- .../campaign/MunicipalityHeaderFilter.tsx | 2 +- .../campaign/MunicipalityLeadershipsPanel.tsx | 2 +- src/components/campaign/MunicipalityList.tsx | 4 +- .../MunicipalityListExpectedVotesControl.tsx | 4 +- .../MunicipalityListGoalCoverageCell.tsx | 2 +- .../campaign/MunicipalityListOverview.tsx | 2 +- .../campaign/MunicipalityMapPanel.tsx | 2 +- .../campaign/MunicipalityPledgesPanel.tsx | 6 +- .../campaign/PledgeEstimateForm.tsx | 2 +- .../StaffMunicipalityVotesDisplay.tsx | 4 +- src/components/campaign/StrictCombobox.tsx | 2 +- src/components/campaign/SupporterForm.tsx | 2 +- src/components/campaign/SupporterList.tsx | 2 +- src/components/campaign/SupporterShareKit.tsx | 2 +- .../campaign/VoteEstimateScenarioField.tsx | 2 +- .../campaign/VoteEstimateScenarioInputs.tsx | 2 +- .../campaign/VoteEstimateScenarioStrip.tsx | 2 +- src/components/campaign/nav.ts | 12 +- src/lib/advisorMunicipalityPortfolio.ts | 2 +- src/lib/campaignConsentKeys.ts | 10 + src/lib/campaignRoles.ts | 18 ++ src/lib/formData.ts | 2 +- src/lib/municipalityCatalog.ts | 2 +- src/lib/onda0ConsentTexts.ts | 10 +- src/{utilities => lib}/phone.ts | 0 src/lib/projectionSheetParse.ts | 2 +- src/lib/schemas/campaign-login.ts | 2 +- src/lib/schemas/primitives.ts | 2 +- src/lib/schemas/supporter.ts | 2 +- src/lib/schemas/votePledge.ts | 2 +- src/{utilities => lib}/slug.ts | 0 src/{utilities => lib}/voteEstimate.ts | 0 src/{utilities => lib}/wordStartFilter.ts | 0 src/utilities/access/shared.ts | 6 +- src/utilities/actionPlanDetailPageData.ts | 2 + src/utilities/actionPlanPageData.ts | 2 + src/utilities/campaignConsent.ts | 12 +- src/utilities/campaignDashboardData.ts | 6 +- src/utilities/campaignDemandData.ts | 2 + src/utilities/campaignInvite.ts | 5 +- src/utilities/campaignMunicipalityScope.ts | 6 +- src/utilities/campaignRelationOptions.ts | 2 + src/utilities/documents.ts | 2 + src/utilities/electionCache.ts | 2 + src/utilities/electionCandidateOptions.ts | 2 + src/utilities/electionResultsImport.ts | 2 + src/utilities/globals.ts | 2 + src/utilities/goalCoverage.ts | 4 +- src/utilities/leaderContactsPageData.ts | 2 + src/utilities/leadershipData.ts | 2 + src/utilities/loadTerritoryOverview.ts | 9 +- .../municipalityCandidateComparison.ts | 2 + src/utilities/municipalityDossierData.ts | 7 +- .../municipalityElectoralBaseline.ts | 2 + src/utilities/municipalityGoalAccount.ts | 7 +- src/utilities/municipalityListUrl.ts | 2 +- src/utilities/municipalityMapContract.ts | 2 +- src/utilities/municipalityMapData.ts | 8 +- src/utilities/municipalityPageData.ts | 9 +- src/utilities/municipalityPotential.ts | 2 +- src/utilities/municipalityUpdatePageData.ts | 2 + src/utilities/municipalityViewModels.ts | 8 +- src/utilities/organizationData.ts | 2 + src/utilities/posts.ts | 2 + src/utilities/stateDeputyData.ts | 2 + src/utilities/supporterListFilters.ts | 18 +- src/utilities/supporterListSqlFilters.ts | 4 +- src/utilities/supporterPageData.ts | 2 + src/utilities/supporterUi.ts | 3 +- src/utilities/supporterViewModels.ts | 2 +- src/utilities/voteEstimateScenarioFields.ts | 2 +- src/utilities/votePledgeData.ts | 173 ++---------------- src/utilities/votePledgeViews.ts | 166 +++++++++++++++++ tests/int/campaignAuth.int.spec.ts | 2 +- .../int/contactPhoneNormalization.int.spec.ts | 4 +- tests/int/municipalityPageData.int.spec.ts | 6 +- tests/int/supporterListFilters.int.spec.ts | 8 +- tests/unit/campaignComponents.unit.spec.ts | 4 +- tests/unit/goalCoverage.unit.spec.ts | 2 +- .../municipalityStaffVoteTotal.unit.spec.ts | 6 +- tests/unit/voteEstimate.unit.spec.ts | 8 +- 111 files changed, 397 insertions(+), 321 deletions(-) create mode 100644 src/lib/campaignConsentKeys.ts create mode 100644 src/lib/campaignRoles.ts rename src/{utilities => lib}/phone.ts (100%) rename src/{utilities => lib}/slug.ts (100%) rename src/{utilities => lib}/voteEstimate.ts (100%) rename src/{utilities => lib}/wordStartFilter.ts (100%) create mode 100644 src/utilities/votePledgeViews.ts diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 1fdfc67e..8059a3a7 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -10,7 +10,7 @@ import { } from '@/lib/formData' import { supporterCreateSchema } from '@/lib/schemas/supporter' import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' -import { sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { sanitizeBrazilianPhoneInput } from '@/lib/phone' export type SupporterFormState = { status?: 'success' diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index ef9337db..efcb9257 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -13,7 +13,7 @@ import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' import { loadAdvisorDetail } from '@/utilities/advisorData' import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' -import { formatBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput } from '@/lib/phone' import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction, diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx index 74e99cb1..1317f42a 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx @@ -50,14 +50,9 @@ import { parseMunicipalityUpdateFeedParams, } from '@/utilities/municipalityUpdatePageData' import { loadAdvisorSummaries } from '@/utilities/municipalityViewModels' -import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' -import { - aggregateMunicipalityPledgesFromRows, - loadMunicipalityPledges, - toMunicipalityPledgeCoverageView, - type MunicipalityPledgeAggregate, - type MunicipalityPledgeCoverageView, -} from '@/utilities/votePledgeData' +import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +import { loadMunicipalityPledges } from '@/utilities/votePledgeData' +import { aggregateMunicipalityPledgesFromRows, toMunicipalityPledgeCoverageView, type MunicipalityPledgeAggregate, type MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions' import { createMunicipalityUpdateFormAction } from './updateFormActions' diff --git a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts index 0c22c3b9..5867026b 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts @@ -8,7 +8,7 @@ import { CAMPAIGN_SESSION_EXPIRED_MESSAGE, mapCampaignFormActionError, } from '@/utilities/campaignFormActionError' -import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' +import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import type { MunicipalityListExpectedVotesResponse } from './types' diff --git a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts index 998f5a7e..6d1bf38a 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts @@ -1,4 +1,4 @@ -import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' +import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' export type MunicipalityListExpectedVotesResponse = | { diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/planos/formActions.ts index 1800dfa7..68d5fd7d 100644 --- a/src/app/(campaign)/campanha/(app)/planos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/formActions.ts @@ -13,7 +13,7 @@ import { parseActionPlanCreateFormData, parseActionPlanUpdateFormData, } from '@/utilities/actionPlanFormData' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' const getActionError = (error: unknown): ActionPlanFormState => { if ( diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index d9d1e230..bf90c4ad 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -17,7 +17,7 @@ import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { sanitizeBrazilianPhoneInput } from '@/lib/phone' import { isUniqueSupporterConflict } from '@/utilities/supporterErrors' export type LeaderSupporterFormState = { diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index ce68e454..7fe2438a 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -24,7 +24,7 @@ import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' import { normalizeVoteEstimateOnSave, toVoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const getFreshStaffActor = ( payload: Payload, diff --git a/src/app/(campaign)/campanha/actions/password.ts b/src/app/(campaign)/campanha/actions/password.ts index e65bd77c..2d478e04 100644 --- a/src/app/(campaign)/campanha/actions/password.ts +++ b/src/app/(campaign)/campanha/actions/password.ts @@ -23,7 +23,7 @@ import { mapCampaignFormActionError, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { normalizeBrazilianPhone } from '@/lib/phone' export const requestCampaignPasswordReset = async ( input: { email: string }, diff --git a/src/app/(campaign)/campanha/actions/supporterImport.ts b/src/app/(campaign)/campanha/actions/supporterImport.ts index 3186308a..f48ba345 100644 --- a/src/app/(campaign)/campanha/actions/supporterImport.ts +++ b/src/app/(campaign)/campanha/actions/supporterImport.ts @@ -17,7 +17,7 @@ import { import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { normalizeBrazilianPhone } from '@/lib/phone' import { relationshipId } from '@/utilities/relationship' import { isPreviewErrorRow, diff --git a/src/app/(campaign)/campanha/actions/votePledge.ts b/src/app/(campaign)/campanha/actions/votePledge.ts index 488e885d..242ecb39 100644 --- a/src/app/(campaign)/campanha/actions/votePledge.ts +++ b/src/app/(campaign)/campanha/actions/votePledge.ts @@ -17,7 +17,7 @@ import { relationshipId, requireRelationshipId } from '@/utilities/relationship' import { normalizeVoteEstimateOnSave, toVoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const STAFF_DECLARE_REQUIRED = 'Somente a coordenação e a assessoria registram votos declarados.' const MUNICIPALITY_NOT_LINKED = diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts index fc4e9345..9d6f06b3 100644 --- a/src/collections/ActionPlan.ts +++ b/src/collections/ActionPlan.ts @@ -27,7 +27,7 @@ import { eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' import { relationshipId } from '@/utilities/relationship' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts index 275839cd..6f6f4231 100644 --- a/src/collections/CampaignDemand.ts +++ b/src/collections/CampaignDemand.ts @@ -24,7 +24,7 @@ import { isCampaignUnrestricted, } from '@/utilities/campaignAccess' import { relationshipId } from '@/utilities/relationship' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index ceeb41bb..0d69d5ab 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -11,7 +11,7 @@ import { canUpdateCampaignUserPhone, } from '@/utilities/campaignAccess' import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { normalizeBrazilianPhone } from '@/lib/phone' import { APIError, type CollectionAfterReadHook, diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 0271bb33..d31af0de 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -7,7 +7,7 @@ import { canManageContacts, canReadContacts, } from '@/utilities/campaignAccess' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { normalizeBrazilianPhone } from '@/lib/phone' import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload' import { APIError } from 'payload' diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index 11e60da7..236a7e91 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -22,7 +22,7 @@ import { getVoteEstimateOrderViolation, VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, type VoteEstimateScenarioFields, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' import { voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupFields, diff --git a/src/collections/Organization.ts b/src/collections/Organization.ts index 62083717..8dfbd0c5 100644 --- a/src/collections/Organization.ts +++ b/src/collections/Organization.ts @@ -8,7 +8,7 @@ import { canReadOrganization, canSetCampaignSystemField, } from '@/utilities/campaignAccess' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') diff --git a/src/collections/Post.ts b/src/collections/Post.ts index 6bacef48..ddaf1372 100644 --- a/src/collections/Post.ts +++ b/src/collections/Post.ts @@ -1,6 +1,6 @@ import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' import type { CollectionConfig } from 'payload' const slug = 'post' diff --git a/src/collections/StateDeputy.ts b/src/collections/StateDeputy.ts index 2640c996..c7ada5f2 100644 --- a/src/collections/StateDeputy.ts +++ b/src/collections/StateDeputy.ts @@ -8,7 +8,7 @@ import { canReadStateDeputy, canSetCampaignSystemField, } from '@/utilities/campaignAccess' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') diff --git a/src/collections/Tag.ts b/src/collections/Tag.ts index 95a038f7..23a10fcf 100644 --- a/src/collections/Tag.ts +++ b/src/collections/Tag.ts @@ -1,6 +1,6 @@ import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' import type { CollectionConfig } from 'payload' const slug = 'tag' diff --git a/src/collections/VotePledge.ts b/src/collections/VotePledge.ts index f3889cd8..7b5f6123 100644 --- a/src/collections/VotePledge.ts +++ b/src/collections/VotePledge.ts @@ -16,7 +16,7 @@ import { type VoteEstimateScenarioFields, getVoteEstimateOrderViolation, VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' import { voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupFields, diff --git a/src/components/PhoneInput.tsx b/src/components/PhoneInput.tsx index b146fa03..6df04b1c 100644 --- a/src/components/PhoneInput.tsx +++ b/src/components/PhoneInput.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react' -import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' import { FormInput } from './FormInput' diff --git a/src/components/campaign/AdvisorDebouncedTextCell.tsx b/src/components/campaign/AdvisorDebouncedTextCell.tsx index e87a7bf4..07d5f53a 100644 --- a/src/components/campaign/AdvisorDebouncedTextCell.tsx +++ b/src/components/campaign/AdvisorDebouncedTextCell.tsx @@ -11,7 +11,7 @@ import { planilhaPlaceholderEmailForAdvisor, } from '@/lib/schemas/advisor' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' -import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' const SAVE_DEBOUNCE_MS = 500 diff --git a/src/components/campaign/AdvisorsTable.tsx b/src/components/campaign/AdvisorsTable.tsx index 5264cd02..0084cedc 100644 --- a/src/components/campaign/AdvisorsTable.tsx +++ b/src/components/campaign/AdvisorsTable.tsx @@ -39,7 +39,7 @@ import { normalizeBrazilianPhone, sanitizeBrazilianPhoneInput, buildWhatsAppUrl, -} from '@/utilities/phone' +} from '@/lib/phone' type MunicipalityRef = { id: number; name: string; slug: string } diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx index 55ffccd0..f553c73e 100644 --- a/src/components/campaign/CampaignDashboard.tsx +++ b/src/components/campaign/CampaignDashboard.tsx @@ -21,7 +21,7 @@ import { import { formatVoteEstimateEndpointsLabel, voteEstimateScenarioLabels, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', diff --git a/src/components/campaign/CampaignProfileSettings.tsx b/src/components/campaign/CampaignProfileSettings.tsx index 4f7d2b4f..23316783 100644 --- a/src/components/campaign/CampaignProfileSettings.tsx +++ b/src/components/campaign/CampaignProfileSettings.tsx @@ -16,7 +16,7 @@ import { Spinner } from '@/components/ui/Spinner' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { fieldError } from '@/utilities/campaignFormFields' import { campaignRoleLabels, type CampaignUserShellView } from '@/utilities/campaignUserProfile' -import { formatBrazilianPhoneDisplay } from '@/utilities/phone' +import { formatBrazilianPhoneDisplay } from '@/lib/phone' type CampaignProfileSettingsProps = { user: CampaignUserShellView & { diff --git a/src/components/campaign/LeaderContactForm.tsx b/src/components/campaign/LeaderContactForm.tsx index 1cba8d76..d5e944b0 100644 --- a/src/components/campaign/LeaderContactForm.tsx +++ b/src/components/campaign/LeaderContactForm.tsx @@ -26,7 +26,7 @@ import { Input } from '@/components/ui/input' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { Spinner } from '@/components/ui/Spinner' import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields' -import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions' const errorProps = (fieldErrors: Record | undefined, field: string) => diff --git a/src/components/campaign/LeaderContactsPanel.tsx b/src/components/campaign/LeaderContactsPanel.tsx index 05d8ac6e..dfe64e4d 100644 --- a/src/components/campaign/LeaderContactsPanel.tsx +++ b/src/components/campaign/LeaderContactsPanel.tsx @@ -12,7 +12,7 @@ import { TableRow, } from '@/components/ui/Table' import type { LeaderContactListItem } from '@/utilities/leaderContactsPageData' -import { formatBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput } from '@/lib/phone' const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', diff --git a/src/components/campaign/MunicipalityEstimateScenarioContext.tsx b/src/components/campaign/MunicipalityEstimateScenarioContext.tsx index 4b7eacdc..102a5bbf 100644 --- a/src/components/campaign/MunicipalityEstimateScenarioContext.tsx +++ b/src/components/campaign/MunicipalityEstimateScenarioContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useMemo, useState, type ReactNode } from 'react' -import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/utilities/voteEstimate' +import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/lib/voteEstimate' type MunicipalityEstimateScenarioContextValue = { scenario: VoteEstimateScenario diff --git a/src/components/campaign/MunicipalityGoalAccountCard.tsx b/src/components/campaign/MunicipalityGoalAccountCard.tsx index fcf14995..ad8a6045 100644 --- a/src/components/campaign/MunicipalityGoalAccountCard.tsx +++ b/src/components/campaign/MunicipalityGoalAccountCard.tsx @@ -19,8 +19,8 @@ import { type MunicipalityGoalCoverage, } from '@/utilities/goalCoverage' import type { MunicipalityPotential, RollOff } from '@/utilities/municipalityPotential' -import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' -import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData' +import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' /** * One `dt`/`dd` pair in the diagnostic grid, with a hover/focus/tap tooltip diff --git a/src/components/campaign/MunicipalityHeaderFilter.tsx b/src/components/campaign/MunicipalityHeaderFilter.tsx index 5ae338b5..73a83135 100644 --- a/src/components/campaign/MunicipalityHeaderFilter.tsx +++ b/src/components/campaign/MunicipalityHeaderFilter.tsx @@ -29,7 +29,7 @@ import { type MunicipalityMultiFilterParam, } from '@/utilities/municipalityListFilters' import { type MunicipalityListState } from '@/utilities/municipalityListUrl' -import { normalizeSearchPhrase } from '@/utilities/wordStartFilter' +import { normalizeSearchPhrase } from '@/lib/wordStartFilter' type MunicipalityHeaderFilterProps = { state: MunicipalityListState diff --git a/src/components/campaign/MunicipalityLeadershipsPanel.tsx b/src/components/campaign/MunicipalityLeadershipsPanel.tsx index 659674e2..73515763 100644 --- a/src/components/campaign/MunicipalityLeadershipsPanel.tsx +++ b/src/components/campaign/MunicipalityLeadershipsPanel.tsx @@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import type { LeadershipRowViewModel } from '@/utilities/leadershipData' -import type { StaffPledgeRow } from '@/utilities/votePledgeData' +import type { StaffPledgeRow } from '@/utilities/votePledgeViews' type MunicipalityLeadershipsPanelProps = { municipalityID: number diff --git a/src/components/campaign/MunicipalityList.tsx b/src/components/campaign/MunicipalityList.tsx index 2851d371..b7c6aff9 100644 --- a/src/components/campaign/MunicipalityList.tsx +++ b/src/components/campaign/MunicipalityList.tsx @@ -56,8 +56,8 @@ import type { import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels, -} from '@/utilities/voteEstimate' -import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' const dateFormatter = new Intl.DateTimeFormat('pt-BR') diff --git a/src/components/campaign/MunicipalityListExpectedVotesControl.tsx b/src/components/campaign/MunicipalityListExpectedVotesControl.tsx index c4b82f85..bc0abe0f 100644 --- a/src/components/campaign/MunicipalityListExpectedVotesControl.tsx +++ b/src/components/campaign/MunicipalityListExpectedVotesControl.tsx @@ -14,8 +14,8 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimatesEqual, type VoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' -import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' const AUTOSAVE_MS = 600 const EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' diff --git a/src/components/campaign/MunicipalityListGoalCoverageCell.tsx b/src/components/campaign/MunicipalityListGoalCoverageCell.tsx index 40ebb41f..c2c5727b 100644 --- a/src/components/campaign/MunicipalityListGoalCoverageCell.tsx +++ b/src/components/campaign/MunicipalityListGoalCoverageCell.tsx @@ -11,7 +11,7 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels, type VoteEstimateScenario, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' /** * E8 "conta da cadeira" list cell: reads the shared scenario picker (same diff --git a/src/components/campaign/MunicipalityListOverview.tsx b/src/components/campaign/MunicipalityListOverview.tsx index 292c85f1..edbeaa50 100644 --- a/src/components/campaign/MunicipalityListOverview.tsx +++ b/src/components/campaign/MunicipalityListOverview.tsx @@ -24,7 +24,7 @@ import { formatVoteEstimateEndpointsLabel, hasAnyVoteEstimate, voteEstimateScenarioLabels, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const SCENARIO_FLASH_MS = 420 diff --git a/src/components/campaign/MunicipalityMapPanel.tsx b/src/components/campaign/MunicipalityMapPanel.tsx index 911c4dcb..ca346240 100644 --- a/src/components/campaign/MunicipalityMapPanel.tsx +++ b/src/components/campaign/MunicipalityMapPanel.tsx @@ -32,7 +32,7 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels, type VoteEstimateScenario, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' type MunicipalityMapPanelProps = { bundle: MunicipalityMapBundle diff --git a/src/components/campaign/MunicipalityPledgesPanel.tsx b/src/components/campaign/MunicipalityPledgesPanel.tsx index 27618a63..33180dcf 100644 --- a/src/components/campaign/MunicipalityPledgesPanel.tsx +++ b/src/components/campaign/MunicipalityPledgesPanel.tsx @@ -5,9 +5,9 @@ import { effectivePledgeVotesForScenario, formatVoteEstimateRange, getVoteEstimateForScenario, -} from '@/utilities/voteEstimate' -import { type StaffPledgeRow } from '@/utilities/votePledgeData' -import { hasAnyEstimate } from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' +import { type StaffPledgeRow } from '@/utilities/votePledgeViews' +import { hasAnyEstimate } from '@/lib/voteEstimate' const voteFormatter = new Intl.NumberFormat('pt-BR') const dateFormatter = new Intl.DateTimeFormat('pt-BR') diff --git a/src/components/campaign/PledgeEstimateForm.tsx b/src/components/campaign/PledgeEstimateForm.tsx index ca368961..e353621c 100644 --- a/src/components/campaign/PledgeEstimateForm.tsx +++ b/src/components/campaign/PledgeEstimateForm.tsx @@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { fieldError } from '@/utilities/campaignFormFields' -import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' +import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' type PledgeEstimateFormProps = { pledgeID: number diff --git a/src/components/campaign/StaffMunicipalityVotesDisplay.tsx b/src/components/campaign/StaffMunicipalityVotesDisplay.tsx index 9d250647..42425358 100644 --- a/src/components/campaign/StaffMunicipalityVotesDisplay.tsx +++ b/src/components/campaign/StaffMunicipalityVotesDisplay.tsx @@ -8,8 +8,8 @@ import { hasAnyVoteEstimate, type VoteEstimateScenario, type VoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' -import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' const voteFormatter = new Intl.NumberFormat('pt-BR') diff --git a/src/components/campaign/StrictCombobox.tsx b/src/components/campaign/StrictCombobox.tsx index 7ade3e8e..99b7c107 100644 --- a/src/components/campaign/StrictCombobox.tsx +++ b/src/components/campaign/StrictCombobox.tsx @@ -11,7 +11,7 @@ import { ComboboxList, } from '@/components/ui/combobox' import type { StrictComboboxOption } from '@/utilities/territoryComboboxOptions' -import { matchesAtWordStart, normalizeSearchPhrase } from '@/utilities/wordStartFilter' +import { matchesAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter' type StrictComboboxProps = { id: string diff --git a/src/components/campaign/SupporterForm.tsx b/src/components/campaign/SupporterForm.tsx index b0cbf236..68d8409d 100644 --- a/src/components/campaign/SupporterForm.tsx +++ b/src/components/campaign/SupporterForm.tsx @@ -25,7 +25,7 @@ import { Spinner } from '@/components/ui/Spinner' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import type { SupporterVoteIntention } from '@/lib/schemas/supporter' import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields' -import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' import { supporterVoteIntentionLabels } from '@/utilities/supporterUi' import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions' diff --git a/src/components/campaign/SupporterList.tsx b/src/components/campaign/SupporterList.tsx index 4e38c815..1801f2c6 100644 --- a/src/components/campaign/SupporterList.tsx +++ b/src/components/campaign/SupporterList.tsx @@ -9,7 +9,7 @@ import { import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { formatBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput } from '@/lib/phone' import { supporterVoteIntentionLabels } from '@/utilities/supporterUi' import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels' diff --git a/src/components/campaign/SupporterShareKit.tsx b/src/components/campaign/SupporterShareKit.tsx index 6246935a..1292097a 100644 --- a/src/components/campaign/SupporterShareKit.tsx +++ b/src/components/campaign/SupporterShareKit.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import { buildWhatsAppUrl } from '@/utilities/phone' +import { buildWhatsAppUrl } from '@/lib/phone' export const SupporterShareKit = ({ supporterName, diff --git a/src/components/campaign/VoteEstimateScenarioField.tsx b/src/components/campaign/VoteEstimateScenarioField.tsx index 5b4bb7be..a78526e9 100644 --- a/src/components/campaign/VoteEstimateScenarioField.tsx +++ b/src/components/campaign/VoteEstimateScenarioField.tsx @@ -8,7 +8,7 @@ import { VOTE_ESTIMATE_SCENARIOS, voteEstimateScenarioLabels, type VoteEstimateScenario, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' export const VOTE_ESTIMATE_SCENARIO_OVERVIEW_HINT = 'Troca o total desta visão geral e os votos na lista. No mapa, a cor só muda com Ano 2026.' diff --git a/src/components/campaign/VoteEstimateScenarioInputs.tsx b/src/components/campaign/VoteEstimateScenarioInputs.tsx index 05d7999c..1e2e8e7d 100644 --- a/src/components/campaign/VoteEstimateScenarioInputs.tsx +++ b/src/components/campaign/VoteEstimateScenarioInputs.tsx @@ -12,7 +12,7 @@ import { voteEstimatesEqual, type VoteEstimateScenario, type VoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const scenarioFieldName = ( fieldPrefix: 'estimatedVotes' | 'expectedVotes', diff --git a/src/components/campaign/VoteEstimateScenarioStrip.tsx b/src/components/campaign/VoteEstimateScenarioStrip.tsx index ef6d41e6..fe0ada5c 100644 --- a/src/components/campaign/VoteEstimateScenarioStrip.tsx +++ b/src/components/campaign/VoteEstimateScenarioStrip.tsx @@ -5,7 +5,7 @@ import { voteEstimateScenarioLabels, type VoteEstimateScenario, type VoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const voteFormatter = new Intl.NumberFormat('pt-BR') diff --git a/src/components/campaign/nav.ts b/src/components/campaign/nav.ts index d704545b..1d8234c8 100644 --- a/src/components/campaign/nav.ts +++ b/src/components/campaign/nav.ts @@ -11,8 +11,8 @@ import { type LucideIcon, } from 'lucide-react' +import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' import type { CampaignUser } from '@/payload-types' -import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess' import { canAccessSupporterArea } from '@/utilities/supporterUi' export type CampaignNavItem = { @@ -57,13 +57,13 @@ export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => return staffNav.filter((item) => { if (item.href === '/campanha/apoiadores') return canAccessSupporterArea(role) - if (item.href === '/campanha/assessores') return isCampaignUnrestrictedRole(role) + if (item.href === '/campanha/assessores') return isUnrestrictedCampaignRole(role) return true }) } export const getCampaignSecondaryNav = (role: CampaignUser['role']): CampaignNavItem[] => - isCampaignStaffRole(role) ? staffSecondaryNav : [] + isStaffCampaignRole(role) ? staffSecondaryNav : [] /** * Compact set for the mobile bottom bar (max 5 items with a home slot). @@ -88,9 +88,3 @@ export const isCampaignNavActive = (pathname: string, href: string): boolean => } return pathname === href || pathname.startsWith(`${href}/`) } - -export const isCampaignStaffRole = (role: CampaignUser['role']): boolean => - isCampaignStaff({ collection: 'campaignUser', role } as CampaignUser) - -const isCampaignUnrestrictedRole = (role: CampaignUser['role']): boolean => - isCampaignUnrestricted({ collection: 'campaignUser', role } as CampaignUser) diff --git a/src/lib/advisorMunicipalityPortfolio.ts b/src/lib/advisorMunicipalityPortfolio.ts index 8570a334..79a0341e 100644 --- a/src/lib/advisorMunicipalityPortfolio.ts +++ b/src/lib/advisorMunicipalityPortfolio.ts @@ -8,7 +8,7 @@ import { type MunicipalityCatalogEntry, } from '@/lib/municipalityCatalog' import { citiesForTseZone } from '@/lib/bahiaTseZones' -import { matchesAtWordStart, normalizeSearchPhrase } from '@/utilities/wordStartFilter' +import { matchesAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter' export type AdvisorMunicipalityIndexEntry = { id: number diff --git a/src/lib/campaignConsentKeys.ts b/src/lib/campaignConsentKeys.ts new file mode 100644 index 00000000..8b4c8d56 --- /dev/null +++ b/src/lib/campaignConsentKeys.ts @@ -0,0 +1,10 @@ +/** + * Stable LGPD consent keys the app resolves at runtime (fail-closed: the flows + * refuse to run while the admin has not created the Consent row with the exact + * key). Client-safe constants — the resolution logic lives in + * `src/utilities/campaignConsent.ts` (server-only). + */ +export const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento' + +export const SUPPORTER_REGISTRATION_CONSENT_KEY = 'apoiador-cadastro' +export const SUPPORTER_VOTE_INTENTION_CONSENT_KEY = 'apoiador-intencao-voto' diff --git a/src/lib/campaignRoles.ts b/src/lib/campaignRoles.ts new file mode 100644 index 00000000..eba6c48b --- /dev/null +++ b/src/lib/campaignRoles.ts @@ -0,0 +1,18 @@ +import type { CampaignUser } from '@/payload-types' + +/** + * Client-safe campaign ROLE predicates — the single source for what each role + * label means. Server-side actor checks (`isCampaignStaff(user)` etc.) derive + * from these in `src/utilities/access/shared.ts`; client components (sidebar, + * nav) use the role-level predicates directly instead of importing the access + * barrel. + */ +export type CampaignRole = CampaignUser['role'] + +/** Staff = coordinator, advisor, or candidate. Leaders are not staff. */ +export const isStaffCampaignRole = (role: CampaignRole): boolean => + role === 'coordinator' || role === 'advisor' || role === 'candidate' + +/** Coordinator or candidate — unrestricted scope (all municipalities, decisions). */ +export const isUnrestrictedCampaignRole = (role: CampaignRole): boolean => + role === 'coordinator' || role === 'candidate' diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 9bb628e7..9a91281e 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -2,7 +2,7 @@ import 'server-only' import { z, type ZodError } from 'zod' -import type { VoteEstimateScenarioFields } from '@/utilities/voteEstimate' +import type { VoteEstimateScenarioFields } from '@/lib/voteEstimate' export class FormDataBoundaryError extends Error { field: string diff --git a/src/lib/municipalityCatalog.ts b/src/lib/municipalityCatalog.ts index d8ce5c1d..f5c70b0e 100644 --- a/src/lib/municipalityCatalog.ts +++ b/src/lib/municipalityCatalog.ts @@ -23,7 +23,7 @@ import { } from '@/lib/bahiaTerritories' import { bahiaTseCityCodes } from '@/lib/bahiaTseCityCodes' import { tseZonesForCity } from '@/lib/bahiaTseZones' -import { slugify } from '@/utilities/slug' +import { slugify } from '@/lib/slug' export type MunicipalityKind = 'municipio' | 'zona' diff --git a/src/lib/onda0ConsentTexts.ts b/src/lib/onda0ConsentTexts.ts index c9605c94..369389f6 100644 --- a/src/lib/onda0ConsentTexts.ts +++ b/src/lib/onda0ConsentTexts.ts @@ -1,13 +1,17 @@ +import { + CAMPAIGN_INVITE_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_VOTE_INTENTION_CONSENT_KEY, +} from '@/lib/campaignConsentKeys' import type { Consent } from '@/payload-types' -import { CAMPAIGN_INVITE_CONSENT_KEY } from '@/utilities/campaignInvite' export const ONDA0_PROVISIONAL_BANNER = 'Texto provisório para MVP e testes internos da plataforma Teqo (campanha Jorge Solla). Não constitui parecer jurídico. Deve ser substituído por texto aprovado pela assessoria jurídica eleitoral antes de coleta de dados de titulares reais em produção.' export const ONDA0_CONSENT_KEYS = { leadershipAutofill: CAMPAIGN_INVITE_CONSENT_KEY, - supporterRegistration: 'apoiador-cadastro', - supporterVoteIntention: 'apoiador-intencao-voto', + supporterRegistration: SUPPORTER_REGISTRATION_CONSENT_KEY, + supporterVoteIntention: SUPPORTER_VOTE_INTENTION_CONSENT_KEY, campaignPush: 'campanha-notificacoes-push', } as const diff --git a/src/utilities/phone.ts b/src/lib/phone.ts similarity index 100% rename from src/utilities/phone.ts rename to src/lib/phone.ts diff --git a/src/lib/projectionSheetParse.ts b/src/lib/projectionSheetParse.ts index d16a7c90..1c14f29e 100644 --- a/src/lib/projectionSheetParse.ts +++ b/src/lib/projectionSheetParse.ts @@ -6,7 +6,7 @@ * Bom → optimistic, Regular → central (média), Mínimo → pessimistic. */ -import type { VoteEstimateScenarioFields } from '@/utilities/voteEstimate' +import type { VoteEstimateScenarioFields } from '@/lib/voteEstimate' /** Parsed sheet triple — all three scenarios present (same keys as expectedVotes). */ export type ProjectionVoteEstimates = Required< diff --git a/src/lib/schemas/campaign-login.ts b/src/lib/schemas/campaign-login.ts index b463e891..d5fee335 100644 --- a/src/lib/schemas/campaign-login.ts +++ b/src/lib/schemas/campaign-login.ts @@ -1,4 +1,4 @@ -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { normalizeBrazilianPhone } from '@/lib/phone' import { z } from 'zod' const campaignIdentifierSchema = z diff --git a/src/lib/schemas/primitives.ts b/src/lib/schemas/primitives.ts index cb8f3f66..f841c2a5 100644 --- a/src/lib/schemas/primitives.ts +++ b/src/lib/schemas/primitives.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { normalizeBrazilianPhone } from '@/lib/phone' export const positiveRelationshipId = z.number().int().positive() diff --git a/src/lib/schemas/supporter.ts b/src/lib/schemas/supporter.ts index 16706f79..cedf0465 100644 --- a/src/lib/schemas/supporter.ts +++ b/src/lib/schemas/supporter.ts @@ -7,7 +7,7 @@ import { positiveRelationshipId, trimmedNullableText, } from '@/lib/schemas/primitives' -import { normalizeSearchPhrase } from '@/utilities/wordStartFilter' +import { normalizeSearchPhrase } from '@/lib/wordStartFilter' export const supporterVoteIntentions = ['certo', 'tende_a_certo', 'indeciso', 'outro'] as const diff --git a/src/lib/schemas/votePledge.ts b/src/lib/schemas/votePledge.ts index abfec7ae..fb5ca169 100644 --- a/src/lib/schemas/votePledge.ts +++ b/src/lib/schemas/votePledge.ts @@ -4,7 +4,7 @@ import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primi import { getVoteEstimateOrderViolation, VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' const optionalEstimate = z.number().int().min(0).max(1_000_000).nullable().optional() diff --git a/src/utilities/slug.ts b/src/lib/slug.ts similarity index 100% rename from src/utilities/slug.ts rename to src/lib/slug.ts diff --git a/src/utilities/voteEstimate.ts b/src/lib/voteEstimate.ts similarity index 100% rename from src/utilities/voteEstimate.ts rename to src/lib/voteEstimate.ts diff --git a/src/utilities/wordStartFilter.ts b/src/lib/wordStartFilter.ts similarity index 100% rename from src/utilities/wordStartFilter.ts rename to src/lib/wordStartFilter.ts diff --git a/src/utilities/access/shared.ts b/src/utilities/access/shared.ts index 65766215..2e57e10d 100644 --- a/src/utilities/access/shared.ts +++ b/src/utilities/access/shared.ts @@ -3,6 +3,7 @@ // Domain modules may import from here; this module must not import from them. // --------------------------------------------------------------------------- +import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' import type { CampaignUser, User } from '@/payload-types' import type { Access, PayloadRequest, Where } from 'payload' @@ -47,15 +48,14 @@ export const isCampaignCandidate = (user: CampaignActor): boolean => /** Coordinator or candidate — unrestricted scope (all municipalities, decisions). */ export const isCampaignUnrestricted = (user: CampaignActor): boolean => - isCampaignCoordinator(user) || isCampaignCandidate(user) + isCampaignUser(user) && isUnrestrictedCampaignRole(user.role) export const isCampaignLeader = (user: CampaignActor): boolean => isCampaignUser(user) && user.role === 'leader' /** Staff = coordinator, advisor, or candidate. Leaders are not staff. */ export const isCampaignStaff = (user: CampaignActor): boolean => - isCampaignUser(user) && - (user.role === 'coordinator' || user.role === 'advisor' || user.role === 'candidate') + isCampaignUser(user) && isStaffCampaignRole(user.role) /** * Eligible relationship targets for advisor assignments (municipality / action plan). diff --git a/src/utilities/actionPlanDetailPageData.ts b/src/utilities/actionPlanDetailPageData.ts index eb9a41ee..b740aac6 100644 --- a/src/utilities/actionPlanDetailPageData.ts +++ b/src/utilities/actionPlanDetailPageData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types' diff --git a/src/utilities/actionPlanPageData.ts b/src/utilities/actionPlanPageData.ts index 4b382ff3..a0a81d9b 100644 --- a/src/utilities/actionPlanPageData.ts +++ b/src/utilities/actionPlanPageData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { ActionPlan, CampaignUser } from '@/payload-types' diff --git a/src/utilities/campaignConsent.ts b/src/utilities/campaignConsent.ts index a7a01d27..797ccc53 100644 --- a/src/utilities/campaignConsent.ts +++ b/src/utilities/campaignConsent.ts @@ -2,12 +2,18 @@ import 'server-only' import type { Payload } from 'payload' +import { + CAMPAIGN_INVITE_CONSENT_KEY, + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_VOTE_INTENTION_CONSENT_KEY, +} from '@/lib/campaignConsentKeys' import type { Consent } from '@/payload-types' -import { CAMPAIGN_INVITE_CONSENT_KEY } from '@/utilities/campaignInvite' import { hashConsentContent } from '@/utilities/consentContentHash' -export const SUPPORTER_REGISTRATION_CONSENT_KEY = 'apoiador-cadastro' -export const SUPPORTER_VOTE_INTENTION_CONSENT_KEY = 'apoiador-intencao-voto' +export { + SUPPORTER_REGISTRATION_CONSENT_KEY, + SUPPORTER_VOTE_INTENTION_CONSENT_KEY, +} from '@/lib/campaignConsentKeys' export type ConsentDescriptor = { id: number diff --git a/src/utilities/campaignDashboardData.ts b/src/utilities/campaignDashboardData.ts index da9b933a..ee1ee620 100644 --- a/src/utilities/campaignDashboardData.ts +++ b/src/utilities/campaignDashboardData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { CampaignUser, VotePledge } from '@/payload-types' @@ -5,8 +7,8 @@ import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope' import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage' import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount' import { relationshipId, requireRelationshipId } from '@/utilities/relationship' -import type { VoteEstimateScenario } from '@/utilities/voteEstimate' -import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeData' +import type { VoteEstimateScenario } from '@/lib/voteEstimate' +import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews' export type StaffDashboardView = { kind: 'staff' diff --git a/src/utilities/campaignDemandData.ts b/src/utilities/campaignDemandData.ts index 5450d5ea..1bbc923f 100644 --- a/src/utilities/campaignDemandData.ts +++ b/src/utilities/campaignDemandData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand' diff --git a/src/utilities/campaignInvite.ts b/src/utilities/campaignInvite.ts index bfebe6e0..0aaaa871 100644 --- a/src/utilities/campaignInvite.ts +++ b/src/utilities/campaignInvite.ts @@ -1,8 +1,9 @@ import { createHash, randomBytes } from 'node:crypto' -import { buildWhatsAppUrl } from '@/utilities/phone' +import { buildWhatsAppUrl } from '@/lib/phone' + +export { CAMPAIGN_INVITE_CONSENT_KEY } from '@/lib/campaignConsentKeys' -export const CAMPAIGN_INVITE_CONSENT_KEY = 'lideranca-autopreenchimento' export const CAMPAIGN_INVITE_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000 export type CampaignInviteKind = 'login' | 'autopreenchimento' diff --git a/src/utilities/campaignMunicipalityScope.ts b/src/utilities/campaignMunicipalityScope.ts index 02b4bd97..c3a736b6 100644 --- a/src/utilities/campaignMunicipalityScope.ts +++ b/src/utilities/campaignMunicipalityScope.ts @@ -5,10 +5,8 @@ import { cache } from 'react' import type { Payload, Where } from 'payload' import type { CampaignUser, Municipality } from '@/payload-types' -import { - aggregatePledgesByMunicipality, - type MunicipalityPledgeAggregate, -} from '@/utilities/votePledgeData' +import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData' +import { type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' /** Superset of the fields the dashboard, map, and list overview consume. */ export type ScopedMunicipalityDoc = Pick< diff --git a/src/utilities/campaignRelationOptions.ts b/src/utilities/campaignRelationOptions.ts index 7f805fa3..ab035c67 100644 --- a/src/utilities/campaignRelationOptions.ts +++ b/src/utilities/campaignRelationOptions.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/RelationMultiSelect' diff --git a/src/utilities/documents.ts b/src/utilities/documents.ts index 8f07e9fb..47a715e3 100644 --- a/src/utilities/documents.ts +++ b/src/utilities/documents.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Config } from '@/payload-types' import configPromise from '@payload-config' diff --git a/src/utilities/electionCache.ts b/src/utilities/electionCache.ts index 3897c198..e1074ea9 100644 --- a/src/utilities/electionCache.ts +++ b/src/utilities/electionCache.ts @@ -1,3 +1,5 @@ +import 'server-only' + /** * Cache tag for every `unstable_cache` entry derived from the immutable TSE * election collections (2014/2018/2022). The data only changes when the seed diff --git a/src/utilities/electionCandidateOptions.ts b/src/utilities/electionCandidateOptions.ts index 32feb13c..69fa5fae 100644 --- a/src/utilities/electionCandidateOptions.ts +++ b/src/utilities/electionCandidateOptions.ts @@ -1,3 +1,5 @@ +import 'server-only' + import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload } from 'payload' diff --git a/src/utilities/electionResultsImport.ts b/src/utilities/electionResultsImport.ts index af2b93c3..8bd398b7 100644 --- a/src/utilities/electionResultsImport.ts +++ b/src/utilities/electionResultsImport.ts @@ -1,3 +1,5 @@ +import 'server-only' + import { and, eq, type AnyColumn } from '@payloadcms/db-postgres/drizzle' import type { Payload } from 'payload' diff --git a/src/utilities/globals.ts b/src/utilities/globals.ts index 394e9e2b..4cac11e9 100644 --- a/src/utilities/globals.ts +++ b/src/utilities/globals.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Config } from '@/payload-types' import configPromise from '@payload-config' diff --git a/src/utilities/goalCoverage.ts b/src/utilities/goalCoverage.ts index 7214f3c7..d537519b 100644 --- a/src/utilities/goalCoverage.ts +++ b/src/utilities/goalCoverage.ts @@ -1,12 +1,12 @@ import { formatElectionNumber } from '@/lib/electionInsights' import type { SuggestedGoalByScenario } from '@/utilities/municipalityPotential' -import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData' +import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' import { getVoteEstimateForScenario, VOTE_ESTIMATE_SCENARIOS, type VoteEstimateScenario, type VoteEstimateScenarioFields, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' /** * E8 "conta da cadeira" — coverage of the goal by auditable pledges. diff --git a/src/utilities/leaderContactsPageData.ts b/src/utilities/leaderContactsPageData.ts index c68b8d05..a3213ef9 100644 --- a/src/utilities/leaderContactsPageData.ts +++ b/src/utilities/leaderContactsPageData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/RelationMultiSelect' diff --git a/src/utilities/leadershipData.ts b/src/utilities/leadershipData.ts index 3543a1c6..39820b31 100644 --- a/src/utilities/leadershipData.ts +++ b/src/utilities/leadershipData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { SupportStatus } from '@/lib/schemas/leadership' diff --git a/src/utilities/loadTerritoryOverview.ts b/src/utilities/loadTerritoryOverview.ts index cf174ed1..76aeaf22 100644 --- a/src/utilities/loadTerritoryOverview.ts +++ b/src/utilities/loadTerritoryOverview.ts @@ -5,11 +5,8 @@ import { cache } from 'react' import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates' import type { Municipality } from '@/payload-types' -import { - aggregatePledgesByMunicipality, - emptyMunicipalityPledgeAggregate, - resolveMunicipalityStaffVoteTotal, -} from '@/utilities/votePledgeData' +import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData' +import { emptyMunicipalityPledgeAggregate, resolveMunicipalityStaffVoteTotal } from '@/utilities/votePledgeViews' import { computeTerritoryRollup, type TerritoryMunicipalityInput, @@ -18,7 +15,7 @@ import { import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario, -} from '@/utilities/voteEstimate' +} from '@/lib/voteEstimate' type MunicipalityDoc = Pick< Municipality, diff --git a/src/utilities/municipalityCandidateComparison.ts b/src/utilities/municipalityCandidateComparison.ts index afd7fdcb..1e65b5a0 100644 --- a/src/utilities/municipalityCandidateComparison.ts +++ b/src/utilities/municipalityCandidateComparison.ts @@ -1,3 +1,5 @@ +import 'server-only' + import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload, type Payload } from 'payload' diff --git a/src/utilities/municipalityDossierData.ts b/src/utilities/municipalityDossierData.ts index 1773723f..a8b30159 100644 --- a/src/utilities/municipalityDossierData.ts +++ b/src/utilities/municipalityDossierData.ts @@ -31,11 +31,8 @@ import { type MunicipalityUpdateViewModel, } from '@/utilities/municipalityUpdatePageData' import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels' -import { - aggregateMunicipalityPledgesFromRows, - loadMunicipalityPledges, - type MunicipalityPledgeAggregate, -} from '@/utilities/votePledgeData' +import { loadMunicipalityPledges } from '@/utilities/votePledgeData' +import { aggregateMunicipalityPledgesFromRows, type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' /** * E16 dossiê — section caps. The dossier is a 1–2 page pre-visit read, not a diff --git a/src/utilities/municipalityElectoralBaseline.ts b/src/utilities/municipalityElectoralBaseline.ts index f552a9a5..372a3c89 100644 --- a/src/utilities/municipalityElectoralBaseline.ts +++ b/src/utilities/municipalityElectoralBaseline.ts @@ -1,3 +1,5 @@ +import 'server-only' + import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload, type Payload } from 'payload' diff --git a/src/utilities/municipalityGoalAccount.ts b/src/utilities/municipalityGoalAccount.ts index a1e66c12..01a40f95 100644 --- a/src/utilities/municipalityGoalAccount.ts +++ b/src/utilities/municipalityGoalAccount.ts @@ -24,11 +24,8 @@ import { zeroByVoteEstimateScenario, type VoteEstimateScenarioFields, type VoteEstimateScenario, -} from '@/utilities/voteEstimate' -import { - emptyMunicipalityPledgeAggregate, - type MunicipalityPledgeAggregate, -} from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import { emptyMunicipalityPledgeAggregate, type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' type GoalAccountReader = CampaignUser | User diff --git a/src/utilities/municipalityListUrl.ts b/src/utilities/municipalityListUrl.ts index fdbf46ac..e5585101 100644 --- a/src/utilities/municipalityListUrl.ts +++ b/src/utilities/municipalityListUrl.ts @@ -18,7 +18,7 @@ import { type RawSearchParams as CampaignListRawSearchParams, } from '@/utilities/campaignListUrl' import { politicalTrendLabels, type PoliticalTrendStatus } from '@/utilities/municipalityLabels' -import { normalizeSearchPhrase } from '@/utilities/wordStartFilter' +import { normalizeSearchPhrase } from '@/lib/wordStartFilter' import type { Municipality } from '@/payload-types' diff --git a/src/utilities/municipalityMapContract.ts b/src/utilities/municipalityMapContract.ts index 8276e297..912c7e0f 100644 --- a/src/utilities/municipalityMapContract.ts +++ b/src/utilities/municipalityMapContract.ts @@ -1,6 +1,6 @@ import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults' import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' -import type { VoteEstimateScenario } from '@/utilities/voteEstimate' +import type { VoteEstimateScenario } from '@/lib/voteEstimate' /** * Client-safe contract of the municipality map: the bundle shape the server diff --git a/src/utilities/municipalityMapData.ts b/src/utilities/municipalityMapData.ts index 2220ce42..2657a687 100644 --- a/src/utilities/municipalityMapData.ts +++ b/src/utilities/municipalityMapData.ts @@ -30,12 +30,8 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO, VOTE_ESTIMATE_SCENARIOS, type VoteEstimateScenario, -} from '@/utilities/voteEstimate' -import { - emptyMunicipalityPledgeAggregate, - resolveMunicipalityStaffVoteTotal, - type MunicipalityPledgeAggregate, -} from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import { emptyMunicipalityPledgeAggregate, resolveMunicipalityStaffVoteTotal, type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' export type { MunicipalityMapSlugEntry, MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' // Client components import the map vocabulary from the contract module; the diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts index fe978abe..75ed7ffe 100644 --- a/src/utilities/municipalityPageData.ts +++ b/src/utilities/municipalityPageData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload, Where } from 'payload' import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories' @@ -33,11 +35,8 @@ import { } from '@/utilities/municipalityViewModels' import { relationshipId } from '@/utilities/relationship' import { loadStateDeputySummaries } from '@/utilities/stateDeputyData' -import { type VoteEstimateScenario } from '@/utilities/voteEstimate' -import { - rollupMunicipalityStaffVotes, - type MunicipalityPledgeAggregate, -} from '@/utilities/votePledgeData' +import { type VoteEstimateScenario } from '@/lib/voteEstimate' +import { rollupMunicipalityStaffVotes, type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' export class MunicipalityNotFoundError extends Error { override name = 'MunicipalityNotFoundError' diff --git a/src/utilities/municipalityPotential.ts b/src/utilities/municipalityPotential.ts index 130048e7..d05026e2 100644 --- a/src/utilities/municipalityPotential.ts +++ b/src/utilities/municipalityPotential.ts @@ -11,7 +11,7 @@ import { import { municipalityCatalog } from '@/lib/municipalityCatalog' import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories' import type { CampaignGoal } from '@/payload-types' -import type { VoteEstimateScenario } from '@/utilities/voteEstimate' +import type { VoteEstimateScenario } from '@/lib/voteEstimate' /** * E8 "conta da cadeira" — derived electoral potential per municipality, built diff --git a/src/utilities/municipalityUpdatePageData.ts b/src/utilities/municipalityUpdatePageData.ts index 7d32e043..b959c321 100644 --- a/src/utilities/municipalityUpdatePageData.ts +++ b/src/utilities/municipalityUpdatePageData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index c74dffea..84dd7f85 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank' @@ -12,9 +14,9 @@ import { toVoteEstimateScenarioViewModel, type VoteEstimateScenario, type VoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' -import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData' -import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' +import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' export const municipalityListSelect = { name: true, diff --git a/src/utilities/organizationData.ts b/src/utilities/organizationData.ts index d13c25fd..2a5d2bda 100644 --- a/src/utilities/organizationData.ts +++ b/src/utilities/organizationData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { OrganizationKind } from '@/lib/schemas/organization' diff --git a/src/utilities/posts.ts b/src/utilities/posts.ts index c38a26d3..6a294734 100644 --- a/src/utilities/posts.ts +++ b/src/utilities/posts.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Post, Tag } from '@/payload-types' import configPromise from '@payload-config' diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts index 274eba2f..c4c64918 100644 --- a/src/utilities/stateDeputyData.ts +++ b/src/utilities/stateDeputyData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types' diff --git a/src/utilities/supporterListFilters.ts b/src/utilities/supporterListFilters.ts index 5dddc9c1..0b91b2b6 100644 --- a/src/utilities/supporterListFilters.ts +++ b/src/utilities/supporterListFilters.ts @@ -1,8 +1,20 @@ import type { Where } from 'payload' import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery' -import { normalizeBrazilianPhone } from '@/utilities/phone' -import type { SupporterListState } from '@/utilities/supporterUi' +import { normalizeBrazilianPhone } from '@/lib/phone' +import type { SupporterVoteIntention } from '@/lib/schemas/supporter' + +/** + * The filters the where-builder actually reads — a structural subset of + * `SupporterListState` (`supporterUi.ts`), declared here so the type flows + * url-module → where-builder without a module cycle. + */ +export type SupporterListFilterInput = { + q?: string + voteIntention?: SupporterVoteIntention + city?: string + municipality?: number +} export type SupporterSearchTerms = { q: string @@ -35,7 +47,7 @@ const buildPayloadSearchWhere = (terms: SupporterSearchTerms): Where => { return { or: searchFilters } } -export const toPayloadWhere = (state: SupporterListState): Where => { +export const toPayloadWhere = (state: SupporterListFilterInput): Where => { const filters: Where[] = [] if (state.q) { diff --git a/src/utilities/supporterListSqlFilters.ts b/src/utilities/supporterListSqlFilters.ts index 8ba5129f..9dc38bcb 100644 --- a/src/utilities/supporterListSqlFilters.ts +++ b/src/utilities/supporterListSqlFilters.ts @@ -6,7 +6,7 @@ import { buildSupporterSearchTerms, type SupporterSearchTerms, } from '@/utilities/supporterListFilters' -import type { SupporterListState } from '@/utilities/supporterUi' +import type { SupporterListFilterInput } from '@/utilities/supporterListFilters' export type AggregateSqlConditions = { conditions: ReturnType[] @@ -28,7 +28,7 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType { +export const toAggregateSqlConditions = (state: SupporterListFilterInput): AggregateSqlConditions => { const conditions: ReturnType[] = [] let needsContactJoin = false diff --git a/src/utilities/supporterPageData.ts b/src/utilities/supporterPageData.ts index cf7d7ced..01e8c333 100644 --- a/src/utilities/supporterPageData.ts +++ b/src/utilities/supporterPageData.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/RelationMultiSelect' diff --git a/src/utilities/supporterUi.ts b/src/utilities/supporterUi.ts index aaa97024..9679eb3b 100644 --- a/src/utilities/supporterUi.ts +++ b/src/utilities/supporterUi.ts @@ -5,6 +5,7 @@ import { resolveBahiaMunicipality, type SupporterVoteIntention, } from '@/lib/schemas/supporter' +import { isStaffCampaignRole } from '@/lib/campaignRoles' import type { CampaignUser, Supporter } from '@/payload-types' import { buildListHref, @@ -122,4 +123,4 @@ export const getSupporterScopeLabel = (total: number): string => /** Staff roles with supporter-area access — matches collection-level supporter access. */ export const canAccessSupporterArea = (role: CampaignUser['role']): boolean => - role === 'coordinator' || role === 'advisor' || role === 'candidate' + isStaffCampaignRole(role) diff --git a/src/utilities/supporterViewModels.ts b/src/utilities/supporterViewModels.ts index 66885793..2a83f62e 100644 --- a/src/utilities/supporterViewModels.ts +++ b/src/utilities/supporterViewModels.ts @@ -1,7 +1,7 @@ import { territoryForCity } from '@/lib/bahiaTerritories' import type { SupporterVoteIntention } from '@/lib/schemas/supporter' import type { CampaignUser, Contact, Municipality, Supporter } from '@/payload-types' -import { formatBrazilianPhoneInput } from '@/utilities/phone' +import { formatBrazilianPhoneInput } from '@/lib/phone' import { isPopulatedRelationship } from '@/utilities/relationship' import { supporterSourceLabels } from '@/utilities/supporterUi' diff --git a/src/utilities/voteEstimateScenarioFields.ts b/src/utilities/voteEstimateScenarioFields.ts index 89bfb80c..5aa36794 100644 --- a/src/utilities/voteEstimateScenarioFields.ts +++ b/src/utilities/voteEstimateScenarioFields.ts @@ -1,7 +1,7 @@ import type { Field } from 'payload' import { canManageCampaignStaffField, canReadCampaignStaffField } from '@/utilities/campaignAccess' -import { voteEstimateScenarioLabels } from '@/utilities/voteEstimate' +import { voteEstimateScenarioLabels } from '@/lib/voteEstimate' /** Shared staff-only trio for pledge estimates and municipality expected totals. */ export const voteEstimateScenarioGroupFields = (): Field[] => [ diff --git a/src/utilities/votePledgeData.ts b/src/utilities/votePledgeData.ts index 383d48bf..30f9e417 100644 --- a/src/utilities/votePledgeData.ts +++ b/src/utilities/votePledgeData.ts @@ -1,161 +1,27 @@ +import 'server-only' + import type { Payload, PayloadRequest } from 'payload' +import { + effectivePledgeVotesForScenario, + toVoteEstimateScenarioViewModel, + VOTE_ESTIMATE_SCENARIOS, +} from '@/lib/voteEstimate' import type { CampaignUser, VotePledge } from '@/payload-types' import { latestIsoTimestamp } from '@/utilities/campaignTime' import { relationshipId, requireRelationshipId } from '@/utilities/relationship' import { - DEFAULT_VOTE_ESTIMATE_SCENARIO, - effectivePledgeVotesForScenario, - resolveMunicipalityStaffVoteTotalForScenario, - toVoteEstimateScenarioViewModel, - VOTE_ESTIMATE_SCENARIOS, - zeroByVoteEstimateScenario, - type VoteEstimateScenario, - type VoteEstimateScenarioFields, - type VoteEstimateScenarioViewModel, -} from '@/utilities/voteEstimate' + createEmptyMunicipalityPledgeAggregate, + pledgeHasAnyEstimate, + type MunicipalityPledgeAggregate, + type StaffPledgeRow, +} from '@/utilities/votePledgeViews' /** - * Pledge aggregation helpers. "Effective" votes on staff surfaces are - * `estimated[S] ?? declaredVotes` per scenario; leader-facing surfaces must - * NEVER receive estimated values or effective totals derived from them. + * Pledge Payload READS. The pure aggregation math and view models live in + * `votePledgeViews.ts` (client-safe contract module). */ -export type { VoteEstimateScenario, VoteEstimateScenarioViewModel } - -export type MunicipalityPledgeAggregate = { - declaredTotal: number - effectiveByScenario: Record - pledgeCount: number - missingEstimateCount: number - /** - * Most recent `declaredAt`/`estimatedAt` across the município's pledges — - * E9 freshness: a commitment nobody has touched in weeks is worth less - * (`docs/research`, l. 339), so the allocation queue can order by how cold - * the signal is. `null` when no pledge carries a date. - */ - lastPledgeAt: string | null -} - -export const createEmptyMunicipalityPledgeAggregate = (): MunicipalityPledgeAggregate => ({ - declaredTotal: 0, - effectiveByScenario: zeroByVoteEstimateScenario(), - pledgeCount: 0, - missingEstimateCount: 0, - lastPledgeAt: null, -}) - -/** Read-only zero aggregate. Never mutate — use createEmptyMunicipalityPledgeAggregate() when writing. */ -export const emptyMunicipalityPledgeAggregate: Readonly = - createEmptyMunicipalityPledgeAggregate() - -export type MunicipalityPledgeCoverageView = { - pledgeCount: number - missingEstimateCount: number - declaredTotal: number - effectiveTotal: number -} - -export const toMunicipalityPledgeCoverageView = ( - aggregate: MunicipalityPledgeAggregate, - scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, -): MunicipalityPledgeCoverageView | null => { - if (aggregate.pledgeCount === 0) return null - return { - pledgeCount: aggregate.pledgeCount, - missingEstimateCount: aggregate.missingEstimateCount, - declaredTotal: aggregate.declaredTotal, - effectiveTotal: aggregate.effectiveByScenario[scenario], - } -} - -/** Staff-facing municipality total: manual expected votes override pledge aggregate when set. */ -export const resolveMunicipalityStaffVoteTotal = ( - expectedVotes: VoteEstimateScenarioFields | null | undefined, - pledgeEffectiveTotal: number, - scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, -): number => - resolveMunicipalityStaffVoteTotalForScenario(expectedVotes, pledgeEffectiveTotal, scenario) - -export const aggregateMunicipalityPledgesFromRows = ( - rows: ReadonlyArray<{ - declaredVotes: number - estimatedVotes?: VoteEstimateScenarioFields | null - declaredAt?: string | null - estimatedAt?: string | null - }>, -): MunicipalityPledgeAggregate => { - const aggregate = createEmptyMunicipalityPledgeAggregate() - - for (const row of rows) { - const declared = row.declaredVotes ?? 0 - aggregate.declaredTotal += declared - for (const scenario of VOTE_ESTIMATE_SCENARIOS) { - aggregate.effectiveByScenario[scenario] += effectivePledgeVotesForScenario( - declared, - row.estimatedVotes, - scenario, - ) - } - aggregate.pledgeCount += 1 - if (!pledgeHasAnyEstimate(row.estimatedVotes)) aggregate.missingEstimateCount += 1 - aggregate.lastPledgeAt = latestIsoTimestamp( - aggregate.lastPledgeAt, - latestIsoTimestamp(row.declaredAt, row.estimatedAt), - ) - } - - return aggregate -} - -export type MunicipalityStaffVoteRollup = { - staffVoteTotal: number - staffVoteTotalByScenario: Record - declaredVotesTotal: number - pledgeCount: number - missingEstimateCount: number -} - -export const rollupMunicipalityStaffVotes = ( - municipalities: ReadonlyArray<{ id: number; expectedVotes?: VoteEstimateScenarioFields | null }>, - pledgeAggregates: Map, - scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, -): MunicipalityStaffVoteRollup => { - let staffVoteTotal = 0 - const staffVoteTotalByScenario = zeroByVoteEstimateScenario() - let declaredVotesTotal = 0 - let pledgeCount = 0 - let missingEstimateCount = 0 - for (const municipality of municipalities) { - const aggregate = pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate - for (const key of VOTE_ESTIMATE_SCENARIOS) { - staffVoteTotalByScenario[key] += resolveMunicipalityStaffVoteTotal( - municipality.expectedVotes, - aggregate.effectiveByScenario[key], - key, - ) - } - staffVoteTotal += resolveMunicipalityStaffVoteTotal( - municipality.expectedVotes, - aggregate.effectiveByScenario[scenario], - scenario, - ) - declaredVotesTotal += aggregate.declaredTotal - pledgeCount += aggregate.pledgeCount - missingEstimateCount += aggregate.missingEstimateCount - } - return { - staffVoteTotal, - staffVoteTotalByScenario, - declaredVotesTotal, - pledgeCount, - missingEstimateCount, - } -} - -const pledgeHasAnyEstimate = (estimated: VoteEstimateScenarioFields | null | undefined): boolean => - estimated?.pessimistic != null || estimated?.central != null || estimated?.optimistic != null - /** * Staff-only aggregate over an already access-checked municipality id set. * Intentional admin bypass: callers pass municipality ids the actor may read. @@ -218,17 +84,6 @@ export const aggregatePledgesByMunicipality = async ( return aggregates } -export type StaffPledgeRow = { - id: number - leadershipID: number - contactName: string - declaredVotes: number - declaredAt: string | null - estimatedVotes: VoteEstimateScenarioViewModel - estimateNote: string | null - estimatedAt: string | null -} - /** Staff view of a municipality's pledges with leadership contact names. */ export const loadMunicipalityPledges = async ( payload: Payload, diff --git a/src/utilities/votePledgeViews.ts b/src/utilities/votePledgeViews.ts new file mode 100644 index 00000000..c60b8f35 --- /dev/null +++ b/src/utilities/votePledgeViews.ts @@ -0,0 +1,166 @@ +import { latestIsoTimestamp } from '@/utilities/campaignTime' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + effectivePledgeVotesForScenario, + resolveMunicipalityStaffVoteTotalForScenario, + VOTE_ESTIMATE_SCENARIOS, + zeroByVoteEstimateScenario, + type VoteEstimateScenario, + type VoteEstimateScenarioFields, + type VoteEstimateScenarioViewModel, +} from '@/lib/voteEstimate' + +/** + * Pledge aggregation VIEWS — pure math and view models, client-safe (the + * contract-module side of `votePledgeData.ts`, which owns the Payload reads + * and is `server-only`). "Effective" votes on staff surfaces are + * `estimated[S] ?? declaredVotes` per scenario; leader-facing surfaces must + * NEVER receive estimated values or effective totals derived from them. + */ + +export type MunicipalityPledgeAggregate = { + declaredTotal: number + effectiveByScenario: Record + pledgeCount: number + missingEstimateCount: number + /** + * Most recent `declaredAt`/`estimatedAt` across the município's pledges — + * E9 freshness: a commitment nobody has touched in weeks is worth less + * (`docs/research`, l. 339), so the allocation queue can order by how cold + * the signal is. `null` when no pledge carries a date. + */ + lastPledgeAt: string | null +} + +export const createEmptyMunicipalityPledgeAggregate = (): MunicipalityPledgeAggregate => ({ + declaredTotal: 0, + effectiveByScenario: zeroByVoteEstimateScenario(), + pledgeCount: 0, + missingEstimateCount: 0, + lastPledgeAt: null, +}) + +/** Read-only zero aggregate. Never mutate — use createEmptyMunicipalityPledgeAggregate() when writing. */ +export const emptyMunicipalityPledgeAggregate: Readonly = + createEmptyMunicipalityPledgeAggregate() + +export type MunicipalityPledgeCoverageView = { + pledgeCount: number + missingEstimateCount: number + declaredTotal: number + effectiveTotal: number +} + +export const toMunicipalityPledgeCoverageView = ( + aggregate: MunicipalityPledgeAggregate, + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, +): MunicipalityPledgeCoverageView | null => { + if (aggregate.pledgeCount === 0) return null + return { + pledgeCount: aggregate.pledgeCount, + missingEstimateCount: aggregate.missingEstimateCount, + declaredTotal: aggregate.declaredTotal, + effectiveTotal: aggregate.effectiveByScenario[scenario], + } +} + +/** Staff-facing municipality total: manual expected votes override pledge aggregate when set. */ +export const resolveMunicipalityStaffVoteTotal = ( + expectedVotes: VoteEstimateScenarioFields | null | undefined, + pledgeEffectiveTotal: number, + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, +): number => + resolveMunicipalityStaffVoteTotalForScenario(expectedVotes, pledgeEffectiveTotal, scenario) + +export const pledgeHasAnyEstimate = ( + estimated: VoteEstimateScenarioFields | null | undefined, +): boolean => + estimated?.pessimistic != null || estimated?.central != null || estimated?.optimistic != null + +export const aggregateMunicipalityPledgesFromRows = ( + rows: ReadonlyArray<{ + declaredVotes: number + estimatedVotes?: VoteEstimateScenarioFields | null + declaredAt?: string | null + estimatedAt?: string | null + }>, +): MunicipalityPledgeAggregate => { + const aggregate = createEmptyMunicipalityPledgeAggregate() + + for (const row of rows) { + const declared = row.declaredVotes ?? 0 + aggregate.declaredTotal += declared + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + aggregate.effectiveByScenario[scenario] += effectivePledgeVotesForScenario( + declared, + row.estimatedVotes, + scenario, + ) + } + aggregate.pledgeCount += 1 + if (!pledgeHasAnyEstimate(row.estimatedVotes)) aggregate.missingEstimateCount += 1 + aggregate.lastPledgeAt = latestIsoTimestamp( + aggregate.lastPledgeAt, + latestIsoTimestamp(row.declaredAt, row.estimatedAt), + ) + } + + return aggregate +} + +export type MunicipalityStaffVoteRollup = { + staffVoteTotal: number + staffVoteTotalByScenario: Record + declaredVotesTotal: number + pledgeCount: number + missingEstimateCount: number +} + +export const rollupMunicipalityStaffVotes = ( + municipalities: ReadonlyArray<{ id: number; expectedVotes?: VoteEstimateScenarioFields | null }>, + pledgeAggregates: Map, + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, +): MunicipalityStaffVoteRollup => { + let staffVoteTotal = 0 + const staffVoteTotalByScenario = zeroByVoteEstimateScenario() + let declaredVotesTotal = 0 + let pledgeCount = 0 + let missingEstimateCount = 0 + for (const municipality of municipalities) { + const aggregate = pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate + for (const key of VOTE_ESTIMATE_SCENARIOS) { + staffVoteTotalByScenario[key] += resolveMunicipalityStaffVoteTotal( + municipality.expectedVotes, + aggregate.effectiveByScenario[key], + key, + ) + } + staffVoteTotal += resolveMunicipalityStaffVoteTotal( + municipality.expectedVotes, + aggregate.effectiveByScenario[scenario], + scenario, + ) + declaredVotesTotal += aggregate.declaredTotal + pledgeCount += aggregate.pledgeCount + missingEstimateCount += aggregate.missingEstimateCount + } + return { + staffVoteTotal, + staffVoteTotalByScenario, + declaredVotesTotal, + pledgeCount, + missingEstimateCount, + } +} + +/** Staff view row of a municipality pledge with the leadership contact name. */ +export type StaffPledgeRow = { + id: number + leadershipID: number + contactName: string + declaredVotes: number + declaredAt: string | null + estimatedVotes: VoteEstimateScenarioViewModel + estimateNote: string | null + estimatedAt: string | null +} diff --git a/tests/int/campaignAuth.int.spec.ts b/tests/int/campaignAuth.int.spec.ts index 74213604..fe513eb3 100644 --- a/tests/int/campaignAuth.int.spec.ts +++ b/tests/int/campaignAuth.int.spec.ts @@ -16,7 +16,7 @@ import { buildWhatsAppUrl, normalizeBrazilianPhone, sanitizeBrazilianPhoneInput, -} from '@/utilities/phone' +} from '@/lib/phone' import { getPayload } from 'payload' import { installCampaignFixtures } from '../helpers/campaignFixtures' diff --git a/tests/int/contactPhoneNormalization.int.spec.ts b/tests/int/contactPhoneNormalization.int.spec.ts index 1e16f807..a9d94cf7 100644 --- a/tests/int/contactPhoneNormalization.int.spec.ts +++ b/tests/int/contactPhoneNormalization.int.spec.ts @@ -6,8 +6,8 @@ const phoneMocks = vi.hoisted(() => ({ normalizeBrazilianPhone: vi.fn<(value: string) => string | null>(), })) -vi.mock('@/utilities/phone', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@/lib/phone', async (importOriginal) => { + const actual = await importOriginal() phoneMocks.normalizeBrazilianPhone.mockImplementation(actual.normalizeBrazilianPhone) return { diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts index 73a3b828..f1698dc7 100644 --- a/tests/int/municipalityPageData.int.spec.ts +++ b/tests/int/municipalityPageData.int.spec.ts @@ -7,10 +7,8 @@ import { beforeAll, describe, expect, it } from 'vitest' import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog' import config from '@/payload.config' import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData' -import { - aggregatePledgesByMunicipality, - rollupMunicipalityStaffVotes, -} from '@/utilities/votePledgeData' +import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData' +import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews' import { installCampaignFixtures } from '../helpers/campaignFixtures' diff --git a/tests/int/supporterListFilters.int.spec.ts b/tests/int/supporterListFilters.int.spec.ts index be6b6ac1..34601d1d 100644 --- a/tests/int/supporterListFilters.int.spec.ts +++ b/tests/int/supporterListFilters.int.spec.ts @@ -8,12 +8,12 @@ import { toAggregateSqlConditions } from '@/utilities/supporterListSqlFilters' describe('supporterListFilters', () => { it('ignores search terms shorter than the contact search minimum', () => { expect(buildSupporterSearchTerms('a')).toBeNull() - expect(toPayloadWhere({ page: 1, q: 'a' })).toEqual({}) - expect(toAggregateSqlConditions({ page: 1, q: 'a' }).needsContactJoin).toBe(false) + expect(toPayloadWhere({ q: 'a' })).toEqual({}) + expect(toAggregateSqlConditions({ q: 'a' }).needsContactJoin).toBe(false) }) it('builds matching payload and SQL search conditions for text queries', () => { - const state = { page: 1, q: 'ana' } + const state = { q: 'ana' } const payloadWhere = toPayloadWhere(state) const aggregate = toAggregateSqlConditions(state) @@ -39,7 +39,7 @@ describe('supporterListFilters', () => { phoneDigits: null, }) - const payloadWhere = toPayloadWhere({ page: 1, q: '(71) 98888-7777' }) + const payloadWhere = toPayloadWhere({ q: '(71) 98888-7777' }) expect(payloadWhere).toEqual({ and: [ { diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 40a42fb6..628c1258 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -19,8 +19,8 @@ import type { MunicipalityAdvisorSummary, MunicipalityListViewModel, } from '@/utilities/municipalityViewModels' -import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' -import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData' +import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' import { stub } from '../helpers/stub' diff --git a/tests/unit/goalCoverage.unit.spec.ts b/tests/unit/goalCoverage.unit.spec.ts index f3fd6cc5..9e9b0eeb 100644 --- a/tests/unit/goalCoverage.unit.spec.ts +++ b/tests/unit/goalCoverage.unit.spec.ts @@ -7,7 +7,7 @@ import { formatGoalCoverageDeficitLabel, } from '@/utilities/goalCoverage' import type { SuggestedGoalByScenario } from '@/utilities/municipalityPotential' -import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData' +import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' /** Suggested goal ladder shaped like `deriveSuggestedGoalsByScenario` output. */ const suggested = ( diff --git a/tests/unit/municipalityStaffVoteTotal.unit.spec.ts b/tests/unit/municipalityStaffVoteTotal.unit.spec.ts index f2424cb4..e3be5fc7 100644 --- a/tests/unit/municipalityStaffVoteTotal.unit.spec.ts +++ b/tests/unit/municipalityStaffVoteTotal.unit.spec.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - createEmptyMunicipalityPledgeAggregate, - resolveMunicipalityStaffVoteTotal, - rollupMunicipalityStaffVotes, -} from '@/utilities/votePledgeData' +import { createEmptyMunicipalityPledgeAggregate, resolveMunicipalityStaffVoteTotal, rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews' describe('resolveMunicipalityStaffVoteTotal', () => { it('falls back to pledge effective total when expectedVotes central is null', () => { diff --git a/tests/unit/voteEstimate.unit.spec.ts b/tests/unit/voteEstimate.unit.spec.ts index 2608c98f..9229582f 100644 --- a/tests/unit/voteEstimate.unit.spec.ts +++ b/tests/unit/voteEstimate.unit.spec.ts @@ -6,12 +6,8 @@ import { getVoteEstimateOrderViolation, normalizeVoteEstimateOnSave, resolveMunicipalityStaffVoteTotalForScenario, -} from '@/utilities/voteEstimate' -import { - createEmptyMunicipalityPledgeAggregate, - resolveMunicipalityStaffVoteTotal, - rollupMunicipalityStaffVotes, -} from '@/utilities/votePledgeData' +} from '@/lib/voteEstimate' +import { createEmptyMunicipalityPledgeAggregate, resolveMunicipalityStaffVoteTotal, rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews' describe('getVoteEstimateOrderViolation', () => { it('returns null when pessimistic ≤ central ≤ optimistic', () => {