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)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 14:38:41 -03:00
parent 23e1f1e83d
commit cf6ae6c47c
111 changed files with 397 additions and 321 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
export type MunicipalityListExpectedVotesResponse =
| {

View file

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

View file

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

View file

@ -24,7 +24,7 @@ import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import {
normalizeVoteEstimateOnSave,
toVoteEstimateScenarioViewModel,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
const getFreshStaffActor = (
payload: Payload,

View file

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

View file

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

View file

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

View file

@ -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() : '')

View file

@ -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() : '')

View file

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

View file

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

View file

@ -22,7 +22,7 @@ import {
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
type VoteEstimateScenarioFields,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,

View file

@ -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() : '')

View file

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

View file

@ -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() : '')

View file

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

View file

@ -16,7 +16,7 @@ import {
type VoteEstimateScenarioFields,
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,

View file

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

View file

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

View file

@ -39,7 +39,7 @@ import {
normalizeBrazilianPhone,
sanitizeBrazilianPhoneInput,
buildWhatsAppUrl,
} from '@/utilities/phone'
} from '@/lib/phone'
type MunicipalityRef = { id: number; name: string; slug: string }

View file

@ -21,7 +21,7 @@ import {
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',

View file

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

View file

@ -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<string, string[]> | undefined, field: string) =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@ import {
formatVoteEstimateEndpointsLabel,
hasAnyVoteEstimate,
voteEstimateScenarioLabels,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
const SCENARIO_FLASH_MS = 420

View file

@ -32,7 +32,7 @@ import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
voteEstimateScenarioLabels,
type VoteEstimateScenario,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
type MunicipalityMapPanelProps = {
bundle: MunicipalityMapBundle

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import {
voteEstimatesEqual,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
const scenarioFieldName = (
fieldPrefix: 'estimatedVotes' | 'expectedVotes',

View file

@ -5,7 +5,7 @@ import {
voteEstimateScenarioLabels,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/utilities/voteEstimate'
} from '@/lib/voteEstimate'
const voteFormatter = new Intl.NumberFormat('pt-BR')

View file

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

View file

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

View file

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

18
src/lib/campaignRoles.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { normalizeBrazilianPhone } from '@/lib/phone'
import { z } from 'zod'
const campaignIdentifierSchema = z

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Config } from '@/payload-types'
import configPromise from '@payload-config'

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'

View file

@ -1,3 +1,5 @@
import 'server-only'
import { and, eq, type AnyColumn } from '@payloadcms/db-postgres/drizzle'
import type { Payload } from 'payload'

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Config } from '@/payload-types'
import configPromise from '@payload-config'

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { SupportStatus } from '@/lib/schemas/leadership'

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type {

View file

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

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { OrganizationKind } from '@/lib/schemas/organization'

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Post, Tag } from '@/payload-types'
import configPromise from '@payload-config'

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'

View file

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

View file

@ -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<typeof sql>[]
@ -28,7 +28,7 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType<typeof
return sql`(${sql.join(searchTerms, sql` OR `)})`
}
export const toAggregateSqlConditions = (state: SupporterListState): AggregateSqlConditions => {
export const toAggregateSqlConditions = (state: SupporterListFilterInput): AggregateSqlConditions => {
const conditions: ReturnType<typeof sql>[] = []
let needsContactJoin = false

View file

@ -1,3 +1,5 @@
import 'server-only'
import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more