From 4806c417c8bcc4629faef6978db649ce1ec8d9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 06:11:29 -0300 Subject: [PATCH] Engineering Pass 3: consolidate patterns and lock them with guards. Fold duplicated campanha access, loaders, list/filter/form shells, and scripts into shared modules; reorganize utilities by domain; stabilize e2e; and document prevention-first audit workflow so the same smells cannot creep back in. --- .cursor/rules/codebase-map.mdc | 4 +- .cursor/skills/close-delivery/SKILL.md | 20 +- .cursor/skills/engineering-audit/SKILL.md | 155 ++++++ .../skills/implement-roadmap-item/SKILL.md | 2 + .cursor/skills/model-selection/SKILL.md | 69 +++ .cursor/skills/ship-to-main/SKILL.md | 8 + .../suggest-next-roadmap-items/SKILL.md | 26 +- .prettierignore | 1 + docs/ARCHITECTURE.md | 2 +- docs/IMPROVE-CODE-QUALITY-PLAN.md | 56 ++- docs/TECH-DEBT.md | 67 ++- docs/TESTING.md | 4 +- docs/plans/entrega-engenharia-p3.md | 212 ++++++++ eslint.config.mjs | 28 ++ playwright.config.ts | 17 + scripts/assert-local-database.mjs | 15 +- scripts/build-bahia-demographics.mjs | 47 +- scripts/build-bahia-geometries.mjs | 58 +-- scripts/build-bahia-tse-city-codes.mjs | 63 +-- scripts/build-election-aggregates.mjs | 9 +- .../build-municipality-zone-geometries.mjs | 49 +- scripts/db-pull.mjs | 7 +- ...erate-remodel-municipalities-migration.mjs | 146 ------ scripts/guard-dev-db.mjs | 7 +- scripts/import-projecao.mjs | 5 +- scripts/lib/cli.mjs | 90 ++++ ...generate-municipality-catalog-snapshot.mjs | 4 +- scripts/seed-consent.mjs | 5 +- scripts/seed-posts.mjs | 10 +- scripts/seed-tse-results.mjs | 49 +- .../(app)/apoiadores/[id]/formActions.ts | 11 +- .../campanha/(app)/apoiadores/[id]/page.tsx | 14 +- .../(app)/apoiadores/importar/page.tsx | 6 +- .../(app)/apoiadores/novo/formActions.ts | 24 +- .../campanha/(app)/apoiadores/novo/page.tsx | 9 +- .../campanha/(app)/apoiadores/page.tsx | 11 +- .../campanha/(app)/assessores/[id]/page.tsx | 12 +- .../campanha/(app)/assessores/page.tsx | 13 +- .../atividades/[slug]/ActivityOverviewTab.tsx | 2 +- .../(app)/atividades/[slug]/editar/page.tsx | 12 +- .../campanha/(app)/atividades/[slug]/page.tsx | 9 +- .../(app)/atividades/giros/formActions.ts | 2 +- .../campanha/(app)/atividades/giros/page.tsx | 23 +- .../campanha/(app)/atividades/nova/page.tsx | 14 +- .../campanha/(app)/atividades/page.tsx | 11 +- .../campanha/(app)/conceitos/page.tsx | 8 +- .../(app)/demandas/[slug]/formActions.ts | 26 +- .../campanha/(app)/demandas/[slug]/page.tsx | 13 +- .../(app)/demandas/nova/formActions.ts | 1 - .../campanha/(app)/demandas/nova/page.tsx | 8 +- .../campanha/(app)/demandas/page.tsx | 11 +- .../(app)/dobradinhas/[slug]/page.tsx | 12 +- .../campanha/(app)/dobradinhas/formActions.ts | 10 +- .../campanha/(app)/dobradinhas/nova/page.tsx | 8 +- .../campanha/(app)/dobradinhas/page.tsx | 13 +- src/app/(campaign)/campanha/(app)/layout.tsx | 4 +- .../campanha/(app)/liderancas/[id]/page.tsx | 14 +- .../liderancas/leadershipStaffEditMessages.ts | 6 +- .../(app)/liderancas/nova/formActions.ts | 6 +- .../campanha/(app)/liderancas/nova/page.tsx | 11 +- .../campanha/(app)/liderancas/page.tsx | 28 +- .../[slug]/MunicipalityDetailTabs.tsx | 26 +- .../municipios/[slug]/editar/formActions.ts | 2 +- .../(app)/municipios/[slug]/editar/page.tsx | 14 +- .../campanha/(app)/municipios/[slug]/page.tsx | 20 +- .../municipios/[slug]/pledgeFormActions.ts | 16 +- .../(app)/municipios/advisors/route.ts | 2 +- .../(app)/municipios/expected-votes/route.ts | 3 +- .../municipalityStaffFormActions.ts | 2 +- .../campanha/(app)/municipios/page.tsx | 24 +- .../(app)/organizacoes/[slug]/formActions.ts | 8 +- .../(app)/organizacoes/[slug]/page.tsx | 12 +- .../(app)/organizacoes/nova/formActions.ts | 12 +- .../campanha/(app)/organizacoes/nova/page.tsx | 11 +- .../campanha/(app)/organizacoes/page.tsx | 17 +- src/app/(campaign)/campanha/(app)/page.tsx | 10 +- .../(campaign)/campanha/(app)/perfil/page.tsx | 13 +- .../campanha/(app)/territorios/page.tsx | 20 +- .../(campaign)/campanha/actions/activity.ts | 20 +- .../(campaign)/campanha/actions/advisor.ts | 12 +- src/app/(campaign)/campanha/actions/demand.ts | 20 +- .../campanha/actions/leaderSupporter.ts | 41 +- .../(campaign)/campanha/actions/leadership.ts | 32 +- .../campanha/actions/municipality.ts | 2 +- .../campanha/actions/organization.ts | 6 +- .../(campaign)/campanha/actions/profile.ts | 2 +- .../campanha/actions/stateDeputy.ts | 4 +- .../(campaign)/campanha/actions/suggestion.ts | 4 +- .../(campaign)/campanha/actions/supporter.ts | 55 +-- .../campanha/actions/supporterImport.ts | 30 +- .../(campaign)/campanha/actions/votePledge.ts | 18 +- .../(campaign)/campanha/actions/webauthn.ts | 2 +- src/app/(campaign)/campanha/login/page.tsx | 2 +- .../campanha/webauthn/login-options/route.ts | 2 +- .../campanha/webauthn/login/route.ts | 6 +- .../webauthn/register-options/route.ts | 2 +- .../campanha/webauthn/register/route.ts | 4 +- .../actions/submitPetitionSignature.ts | 16 +- src/collections/Activity.ts | 21 +- src/collections/AllocationDecision.ts | 16 +- src/collections/CampaignDemand.ts | 30 +- src/collections/CampaignInvite.ts | 17 +- src/collections/Contact.ts | 6 +- src/collections/Leadership.ts | 32 +- src/collections/Municipality.ts | 4 +- src/collections/MunicipalityUpdate.ts | 2 +- src/collections/Organization.ts | 29 +- src/collections/StateDeputy.ts | 29 +- src/collections/Supporter.ts | 22 +- src/collections/VotePledge.ts | 18 +- src/components/PetitionForm.tsx | 4 - .../campaign/activity/ActivityCard.tsx | 2 +- .../campaign/activity/ActivityFilters.tsx | 179 +++---- .../campaign/activity/ActivityForm.tsx | 4 +- .../campaign/activity/ActivityResultForm.tsx | 21 +- .../activity/ActivityTaskChecklist.tsx | 2 +- .../campaign/activity/ActivityTaskFields.tsx | 2 +- .../campaign/activity/ActivityUpdateFeed.tsx | 2 +- .../campaign/activity/ActivityUpdateForm.tsx | 19 +- .../advisor/AdvisorDebouncedTextCell.tsx | 3 +- .../advisor/AdvisorPasswordResetButton.tsx | 7 +- .../campaign/advisor/AdvisorsTable.tsx | 7 +- .../campaign/dashboard/CampaignDashboard.tsx | 6 +- .../dashboard/CampaignDataFreshness.tsx | 2 +- src/components/campaign/demand/DemandForm.tsx | 8 +- .../campaign/invite/CampaignInviteForm.tsx | 12 +- .../campaign/leadership/LeaderContactForm.tsx | 28 +- .../campaign/leadership/LeadershipFilters.tsx | 4 +- .../campaign/leadership/LeadershipForm.tsx | 10 +- .../leadership/LeadershipHeaderFilter.tsx | 4 +- .../leadership/LeadershipInternalForm.tsx | 17 +- .../LeadershipListSupportStatusControl.tsx | 2 +- .../leadership/LeadershipSortableHead.tsx | 4 +- .../leadership/SupportStatusBadge.tsx | 2 +- .../campaign/map/MapFeatureReadout.tsx | 2 +- .../campaign/map/MunicipalityMapPanel.tsx | 6 +- .../map/MunicipalityMapPanelDynamic.tsx | 2 +- .../MunicipalityAdvisorAvatarStack.tsx | 2 +- .../municipality/MunicipalityAdvisorsForm.tsx | 15 +- .../municipality/MunicipalityBaselineCard.tsx | 4 +- .../MunicipalityCandidateComparisonTable.tsx | 2 +- .../municipality/MunicipalityDossier.tsx | 10 +- .../municipality/MunicipalityFilters.tsx | 4 +- .../MunicipalityGoalAccountCard.tsx | 10 +- .../municipality/MunicipalityHeaderFilter.tsx | 6 +- .../MunicipalityLeadershipsPanel.tsx | 2 +- .../municipality/MunicipalityList.tsx | 10 +- .../MunicipalityListAdvisorsControl.tsx | 2 +- .../MunicipalityListGoalCoverageCell.tsx | 2 +- .../municipality/MunicipalityListOverview.tsx | 4 +- .../MunicipalityListSignalControl.tsx | 20 +- .../MunicipalityListTrendControl.tsx | 5 +- .../municipality/MunicipalityPledgesPanel.tsx | 4 +- .../MunicipalityPriorityIndicator.tsx | 2 +- .../municipality/MunicipalitySortableHead.tsx | 4 +- .../municipality/MunicipalityStrategyCard.tsx | 4 +- .../municipality/MunicipalityStrategyForm.tsx | 4 +- .../municipality/MunicipalityTabNav.tsx | 2 +- .../municipality/MunicipalityUpdateFeed.tsx | 2 +- .../municipality/MunicipalityUpdateForm.tsx | 13 +- .../MunicipalityVisitEligibilityCard.tsx | 6 +- .../SaveMunicipalityFilterControl.tsx | 6 +- .../municipality/TerritoryFilters.tsx | 4 +- .../municipality/TerritoryHeaderFilter.tsx | 7 +- .../campaign/municipality/TerritoryList.tsx | 9 +- .../municipality/TerritoryListColumns.tsx | 15 +- .../municipality/TerritorySortableHead.tsx | 4 +- .../organization/OrganizationForm.tsx | 13 +- .../shared/CampaignCollapsibleFilterPanel.tsx | 66 +++ .../shared/CampaignFormActionMessage.tsx | 55 +++ .../campaign/shared/CampaignSearchForm.tsx | 55 ++- .../campaign/shared/StrictCombobox.tsx | 2 +- .../shared/useCampaignFilterValues.ts | 64 +++ .../shared/useCampaignFormSuccessToast.ts | 28 ++ .../shared/useMunicipalitySavedFilters.ts | 2 +- .../campaign/shell/CampaignSidebar.tsx | 2 +- .../shell/MunicipalityNavSavedFilters.tsx | 2 +- src/components/campaign/shell/nav.ts | 2 +- .../campaign/stateDeputy/StateDeputyForm.tsx | 13 +- .../campaign/suggestion/SuggestionsPanel.tsx | 4 +- .../campaign/supporter/SupporterFilters.tsx | 283 +++++------ .../campaign/supporter/SupporterForm.tsx | 31 +- .../supporter/SupporterImportWizard.tsx | 4 +- .../campaign/supporter/SupporterList.tsx | 4 +- .../supporter/SupporterListOverview.tsx | 2 +- .../supporter/VoteIntentionControl.tsx | 19 +- .../campaign/tour/CalendarPhaseNote.tsx | 2 +- .../campaign/tour/TourComposerForm.tsx | 4 +- .../campaign/votePledge/DeclareVotesForm.tsx | 13 +- .../votePledge/PledgeEstimateForm.tsx | 8 +- .../votePledge/VoteEstimateScenarioStrip.tsx | 5 +- src/lib/campaignConsentKeys.ts | 9 + src/lib/campaignInlineInput.ts | 7 + src/{utilities => lib}/campaignTime.ts | 0 src/lib/electionResults.ts | 39 +- src/lib/electionYears.ts | 10 + src/lib/formData.ts | 7 +- src/lib/municipalityNameAliases.ts | 63 +++ src/lib/phone.ts | 10 +- src/{utilities => lib}/relationship.ts | 0 src/lib/schemas/activity.ts | 15 + src/lib/schemas/advisor.ts | 16 +- src/lib/schemas/campaignDemand.ts | 32 ++ src/lib/schemas/campaignPassword.ts | 11 +- src/lib/schemas/invite.ts | 3 +- src/lib/schemas/leadership.ts | 8 + src/lib/schemas/municipalityUpdate.ts | 8 +- src/lib/schemas/organization.ts | 9 + src/lib/schemas/primitives.ts | 7 +- src/lib/schemas/supporter.ts | 41 +- src/lib/schemas/votePledge.ts | 30 +- src/lib/suggestionCatalog.ts | 19 +- src/lib/territorialClassAnchors.ts | 16 + src/lib/text.ts | 6 + src/lib/voteEstimate.ts | 12 +- src/utilities/access/activities.ts | 7 +- src/utilities/access/contacts.ts | 130 +++-- src/utilities/access/demands.ts | 22 +- src/utilities/access/invites.ts | 2 +- src/utilities/access/leaderships.ts | 100 ++-- src/utilities/access/municipalities.ts | 42 +- src/utilities/access/municipalityUpdates.ts | 20 +- src/utilities/access/shared.ts | 61 +++ src/utilities/access/supporters.ts | 23 +- src/utilities/access/votePledges.ts | 22 +- src/utilities/activityDetailPageData.ts | 2 +- src/utilities/activityFormData.ts | 4 +- src/utilities/activityLeadershipOptions.ts | 2 +- src/utilities/activityPageData.ts | 12 +- src/utilities/activityViewModels.ts | 2 +- src/utilities/advisorData.ts | 2 +- src/utilities/campaignAuditFields.ts | 61 +++ src/utilities/campaignConsent.ts | 29 +- src/utilities/campaignDashboardData.ts | 49 +- src/utilities/campaignDemandData.ts | 65 +-- src/utilities/campaignInviteCreation.ts | 2 +- src/utilities/campaignInvitePageData.ts | 8 +- src/utilities/campaignInviteRedemption.ts | 2 +- src/utilities/campaignJsonMutationRoute.ts | 2 + src/utilities/campaignListUrl.ts | 27 ++ src/utilities/campaignPageActor.ts | 39 ++ src/utilities/campaignRelationOptions.ts | 2 +- src/utilities/contactPhoneInvariant.ts | 10 + .../dashboardPriorityMunicipalities.ts | 5 +- src/utilities/entityNotFound.ts | 14 + .../{ => leadership}/leadershipData.ts | 25 +- .../{ => leadership}/leadershipLabels.ts | 0 .../{ => leadership}/leadershipListFilters.ts | 9 +- .../{ => leadership}/leadershipListUrl.ts | 23 +- .../{ => leadership}/leadershipUi.ts | 0 src/utilities/loadNamesByIds.ts | 97 ++++ .../campaignMunicipalityScope.ts | 63 ++- .../{ => municipality}/goalCoverage.ts | 2 +- .../municipalityCandidateComparison.ts | 2 +- .../municipality/municipalityCatalogCache.ts | 14 + .../municipalityDetailTabUi.ts | 0 .../municipalityDossierData.ts | 12 +- .../municipalityElectionGeography.ts | 0 .../municipalityElectoralBaseline.ts | 2 +- .../municipalityGoalAccount.ts | 8 +- .../{ => municipality}/municipalityLabels.ts | 6 +- .../municipalityListFilters.ts | 6 +- .../{ => municipality}/municipalityListUrl.ts | 59 +-- .../municipalityMapContract.ts | 4 +- .../{ => municipality}/municipalityMapData.ts | 22 +- .../municipalityMapNavigation.ts | 0 .../municipalityPageData.ts | 35 +- .../municipalityPortfolioIndex.ts | 2 +- .../municipalityPotential.ts | 0 .../municipalityRevalidation.ts | 13 +- .../municipalitySavedFilters.ts | 0 .../{ => municipality}/municipalitySignal.ts | 5 +- .../municipalityTerritorialClass.ts | 13 +- .../municipalityTriggers.ts | 54 +-- .../municipalityUpdatePageData.ts | 18 +- .../municipalityViewModels.ts | 10 +- src/utilities/organizationData.ts | 16 +- src/utilities/postgresTransactionLocks.ts | 4 + src/utilities/revalidateRequest.ts | 2 +- src/utilities/signatureExport.ts | 2 +- src/utilities/stateDeputyData.ts | 2 +- src/utilities/stateDeputyListUrl.ts | 23 +- .../{ => supporter}/supporterErrors.ts | 0 .../{ => supporter}/supporterImport.ts | 0 .../{ => supporter}/supporterImportBulk.ts | 0 .../{ => supporter}/supporterImportToken.ts | 4 + .../{ => supporter}/supporterListFilters.ts | 0 .../supporterListOverviewAggregate.ts | 6 +- .../supporterListSqlFilters.ts | 4 +- .../{ => supporter}/supporterPageData.ts | 18 +- src/utilities/{ => supporter}/supporterUi.ts | 2 +- .../{ => supporter}/supporterViewModels.ts | 4 +- .../{ => territory}/loadTerritoryOverview.ts | 8 +- .../territoryComboboxOptions.ts | 0 .../territoryIntraCaptureBenchmark.ts | 4 +- .../{ => territory}/territoryListFilters.ts | 2 +- .../{ => territory}/territoryListLabels.ts | 2 +- .../{ => territory}/territoryListUrl.ts | 45 +- .../{ => territory}/territoryOverview.ts | 6 +- .../territory/territoryRegionParam.ts | 27 ++ src/utilities/{ => visit}/visitEligibility.ts | 4 +- src/utilities/{ => visit}/visitPlannerData.ts | 14 +- src/utilities/{ => visit}/visitPlannerUrl.ts | 0 .../{ => visit}/visitPlannerViews.ts | 6 +- src/utilities/votePledgeData.ts | 78 ++- src/utilities/votePledgeViews.ts | 11 +- .../campaignWebAuthnCeremony.ts | 13 +- .../campaignWebAuthnChallenge.ts | 0 .../{ => webauthn}/campaignWebAuthnConfig.ts | 0 .../{ => webauthn}/campaignWebAuthnSession.ts | 0 tests/e2e/campaignActivity.e2e.spec.ts | 13 +- tests/e2e/campaignAuth.e2e.spec.ts | 15 +- tests/e2e/campaignColumnPicker.e2e.spec.ts | 30 +- tests/e2e/campaignConcepts.e2e.spec.ts | 29 +- tests/e2e/campaignLeaderships.e2e.spec.ts | 33 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 237 +++------ .../campaignNearestMunicipality.e2e.spec.ts | 48 +- tests/e2e/campaignSavedFilters.e2e.spec.ts | 30 +- tests/e2e/campaignTerritories.e2e.spec.ts | 44 +- tests/e2e/campaignWebAuthn.e2e.spec.ts | 15 +- tests/e2e/campaignZoneMap.e2e.spec.ts | 13 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 70 +++ tests/helpers/campaignFixtures.ts | 10 + tests/int/campaignEntityListData.int.spec.ts | 2 +- .../int/campaignMunicipalityScope.int.spec.ts | 2 +- ...aignMunicipalityUpdatePageData.int.spec.ts | 2 +- tests/int/campaignSuggestions.int.spec.ts | 6 +- tests/int/campaignSupporter.int.spec.ts | 4 +- tests/int/campaignVotePledge.int.spec.ts | 43 ++ tests/int/campaignWebAuthnSession.int.spec.ts | 2 +- .../int/collectionAccessLockdown.int.spec.ts | 2 +- tests/int/municipalityDossierData.int.spec.ts | 6 +- tests/int/municipalityMapData.int.spec.ts | 2 +- tests/int/municipalityPageData.int.spec.ts | 4 +- tests/int/relationship.int.spec.ts | 6 +- tests/int/submitPetitionSignature.int.spec.ts | 153 ++++++ tests/int/supporterListFilters.int.spec.ts | 7 +- tests/int/visitPlannerData.int.spec.ts | 5 +- tests/unit/accessShared.unit.spec.ts | 84 ++++ tests/unit/campaignComponents.unit.spec.ts | 6 +- tests/unit/campaignConsent.unit.spec.ts | 3 + .../campaignFormActionMessage.unit.spec.tsx | 38 ++ .../campaignWebAuthnChallenge.unit.spec.ts | 2 +- .../unit/campaignWebAuthnConfig.unit.spec.ts | 2 +- tests/unit/codebaseConventions.unit.spec.ts | 458 +++++++++++++++++- ...shboardPriorityMunicipalities.unit.spec.ts | 2 +- tests/unit/goalCoverage.unit.spec.ts | 4 +- tests/unit/leadershipListUrl.unit.spec.ts | 4 +- .../municipalityGeographyParts.unit.spec.ts | 2 +- tests/unit/municipalityList.unit.spec.ts | 8 +- .../municipalityMapNavigation.unit.spec.ts | 2 +- .../unit/municipalityNameAliases.unit.spec.ts | 26 + tests/unit/municipalityPotential.unit.spec.ts | 8 +- .../municipalitySavedFilters.unit.spec.ts | 2 +- .../municipalityScopeCacheKey.unit.spec.ts | 88 ++++ .../municipalityTerritorialClass.unit.spec.ts | 2 +- tests/unit/revalidateRequest.unit.spec.ts | 2 +- tests/unit/supporterUi.unit.spec.ts | 2 +- tests/unit/territoryListUrl.unit.spec.ts | 4 +- tests/unit/territoryOverview.unit.spec.ts | 2 +- tests/unit/visitEligibility.unit.spec.ts | 8 +- vitest.unit.config.mts | 2 +- 362 files changed, 4271 insertions(+), 2813 deletions(-) create mode 100644 .cursor/skills/engineering-audit/SKILL.md create mode 100644 .cursor/skills/model-selection/SKILL.md create mode 100644 docs/plans/entrega-engenharia-p3.md delete mode 100644 scripts/generate-remodel-municipalities-migration.mjs create mode 100644 scripts/lib/cli.mjs create mode 100644 src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx create mode 100644 src/components/campaign/shared/CampaignFormActionMessage.tsx create mode 100644 src/components/campaign/shared/useCampaignFilterValues.ts create mode 100644 src/components/campaign/shared/useCampaignFormSuccessToast.ts create mode 100644 src/lib/campaignInlineInput.ts rename src/{utilities => lib}/campaignTime.ts (100%) create mode 100644 src/lib/electionYears.ts create mode 100644 src/lib/municipalityNameAliases.ts rename src/{utilities => lib}/relationship.ts (100%) create mode 100644 src/lib/text.ts create mode 100644 src/utilities/campaignAuditFields.ts create mode 100644 src/utilities/campaignPageActor.ts create mode 100644 src/utilities/entityNotFound.ts rename src/utilities/{ => leadership}/leadershipData.ts (92%) rename src/utilities/{ => leadership}/leadershipLabels.ts (100%) rename src/utilities/{ => leadership}/leadershipListFilters.ts (96%) rename src/utilities/{ => leadership}/leadershipListUrl.ts (95%) rename src/utilities/{ => leadership}/leadershipUi.ts (100%) create mode 100644 src/utilities/loadNamesByIds.ts rename src/utilities/{ => municipality}/campaignMunicipalityScope.ts (61%) rename src/utilities/{ => municipality}/goalCoverage.ts (99%) rename src/utilities/{ => municipality}/municipalityCandidateComparison.ts (98%) create mode 100644 src/utilities/municipality/municipalityCatalogCache.ts rename src/utilities/{ => municipality}/municipalityDetailTabUi.ts (100%) rename src/utilities/{ => municipality}/municipalityDossierData.ts (93%) rename src/utilities/{ => municipality}/municipalityElectionGeography.ts (100%) rename src/utilities/{ => municipality}/municipalityElectoralBaseline.ts (99%) rename src/utilities/{ => municipality}/municipalityGoalAccount.ts (96%) rename src/utilities/{ => municipality}/municipalityLabels.ts (98%) rename src/utilities/{ => municipality}/municipalityListFilters.ts (98%) rename src/utilities/{ => municipality}/municipalityListUrl.ts (92%) rename src/utilities/{ => municipality}/municipalityMapContract.ts (98%) rename src/utilities/{ => municipality}/municipalityMapData.ts (91%) rename src/utilities/{ => municipality}/municipalityMapNavigation.ts (100%) rename src/utilities/{ => municipality}/municipalityPageData.ts (95%) rename src/utilities/{ => municipality}/municipalityPortfolioIndex.ts (98%) rename src/utilities/{ => municipality}/municipalityPotential.ts (100%) rename src/utilities/{ => municipality}/municipalityRevalidation.ts (62%) rename src/utilities/{ => municipality}/municipalitySavedFilters.ts (100%) rename src/utilities/{ => municipality}/municipalitySignal.ts (93%) rename src/utilities/{ => municipality}/municipalityTerritorialClass.ts (97%) rename src/utilities/{ => municipality}/municipalityTriggers.ts (92%) rename src/utilities/{ => municipality}/municipalityUpdatePageData.ts (85%) rename src/utilities/{ => municipality}/municipalityViewModels.ts (97%) rename src/utilities/{ => supporter}/supporterErrors.ts (100%) rename src/utilities/{ => supporter}/supporterImport.ts (100%) rename src/utilities/{ => supporter}/supporterImportBulk.ts (100%) rename src/utilities/{ => supporter}/supporterImportToken.ts (93%) rename src/utilities/{ => supporter}/supporterListFilters.ts (100%) rename src/utilities/{ => supporter}/supporterListOverviewAggregate.ts (95%) rename src/utilities/{ => supporter}/supporterListSqlFilters.ts (95%) rename src/utilities/{ => supporter}/supporterPageData.ts (94%) rename src/utilities/{ => supporter}/supporterUi.ts (98%) rename src/utilities/{ => supporter}/supporterViewModels.ts (96%) rename src/utilities/{ => territory}/loadTerritoryOverview.ts (96%) rename src/utilities/{ => territory}/territoryComboboxOptions.ts (100%) rename src/utilities/{ => territory}/territoryIntraCaptureBenchmark.ts (96%) rename src/utilities/{ => territory}/territoryListFilters.ts (97%) rename src/utilities/{ => territory}/territoryListLabels.ts (98%) rename src/utilities/{ => territory}/territoryListUrl.ts (83%) rename src/utilities/{ => territory}/territoryOverview.ts (98%) create mode 100644 src/utilities/territory/territoryRegionParam.ts rename src/utilities/{ => visit}/visitEligibility.ts (99%) rename src/utilities/{ => visit}/visitPlannerData.ts (96%) rename src/utilities/{ => visit}/visitPlannerUrl.ts (100%) rename src/utilities/{ => visit}/visitPlannerViews.ts (97%) rename src/utilities/{ => webauthn}/campaignWebAuthnCeremony.ts (94%) rename src/utilities/{ => webauthn}/campaignWebAuthnChallenge.ts (100%) rename src/utilities/{ => webauthn}/campaignWebAuthnConfig.ts (100%) rename src/utilities/{ => webauthn}/campaignWebAuthnSession.ts (100%) create mode 100644 tests/int/submitPetitionSignature.int.spec.ts create mode 100644 tests/unit/accessShared.unit.spec.ts create mode 100644 tests/unit/campaignFormActionMessage.unit.spec.tsx create mode 100644 tests/unit/municipalityNameAliases.unit.spec.ts create mode 100644 tests/unit/municipalityScopeCacheKey.unit.spec.ts diff --git a/.cursor/rules/codebase-map.mdc b/.cursor/rules/codebase-map.mdc index 324910ce..451f8c77 100644 --- a/.cursor/rules/codebase-map.mdc +++ b/.cursor/rules/codebase-map.mdc @@ -18,6 +18,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - `src/app/(campaign)/campanha` — ferramenta interna (auth `campaignUser`, cookie `campaign-token`; barreira no layout de `(app)`). Server actions em `campanha/actions/*`; `formActions.ts` por rota são cascas sobre `runCampaignFormAction` / `runCampaignRedirectFormAction` (`utilities/campaignFormActionError.ts`). - `src/collections/` + `src/globals/` — modelo de dados Payload; TODA collection declara `access`; schema muda só por migration (`pnpm migrate:create`). - `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`. +- `src/utilities//` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`). - `src/components/campaign//` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos). - `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona). - `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`). @@ -31,7 +32,8 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle). - Filtros salvos (B18) — só municípios: `utilities/municipalitySavedFilters.ts` (`localStorage`, identidade = href canônico, ordem alfabética) + hook `shared/useMunicipalitySavedFilters` + `municipality/SaveMunicipalityFilterControl` (barra) + `shell/MunicipalityNavSavedFilters` (submenu). O casamento URL↔salvo é `lib/listQueryMatch.ts` e **tem de continuar puro**: a sidebar está no layout de `(app)`, então importar o serializador de URL do domínio ali custa ~21 kB em toda rota `/campanha`. - Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`. -- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora. +- Exceções documentadas: atividades = cards; comparação de candidatos, LeaderContacts e preview de import ficam fora. +- `src/lib/formData.ts` é o único módulo de `lib/` marcado `server-only` (FormData é fronteira server); todo o resto de `lib/` é client-safe. - Detalhe por tabs: `utilities/detailTabUi.ts` (factory) + sections RSC colocadas na rota (`municipios/[slug]/MunicipalityDetailTabs.tsx`, `atividades/[slug]/ActivityOverviewTab.tsx`). ## Achar o dono de uma feature diff --git a/.cursor/skills/close-delivery/SKILL.md b/.cursor/skills/close-delivery/SKILL.md index 33f64eee..61c80db0 100644 --- a/.cursor/skills/close-delivery/SKILL.md +++ b/.cursor/skills/close-delivery/SKILL.md @@ -30,6 +30,7 @@ Não reimplemente a lógica delas aqui — só orquestre + aplique o override de ## Checklist ``` +- [ ] 0. Gate de modelo (skill model-selection: pool model; pare se metered premium — mesmo critério do Passo 0 de ship-to-main) - [ ] 1. rebase-on-main (completo; pare se abortar/perguntar) - [ ] 2. capture-review-debts com auto-confirm (override do Passo 5) - [ ] 3. ship-to-main (completo) @@ -58,15 +59,16 @@ Nesses casos: reporte o bloqueio, **não** rode `ship-to-main` ainda (working tr ## Regras de orquestração -| Situação | Ação | -| ------------------------------------ | ------------------------------------------------------------------------- | -| Rebase no-op (já sync) | Siga para debts | -| Rebase com conflitos resolvidos | Siga para debts | -| Rebase abortado / precisa decisão | **Pare** — não debts, não ship | -| Debts auto-confirm OK (ou no-op) | Siga para ship | -| Debts bloqueado (lock / ambiguidade) | **Pare** — não ship | -| Ship: feature atrás de main | Não deve ocorrer se Passo 1 OK; se ocorrer, re-rode rebase antes de merge | -| Ship: branch é `main` | Pare (guard da filha) | +| Situação | Ação | +| ---------------------------------------- | ------------------------------------------------------------------------- | +| Gate de modelo (Passo 0) sem confirmação | **Pare** — aguarde troca de modelo ou aceite explícito do custo | +| Rebase no-op (já sync) | Siga para debts | +| Rebase com conflitos resolvidos | Siga para debts | +| Rebase abortado / precisa decisão | **Pare** — não debts, não ship | +| Debts auto-confirm OK (ou no-op) | Siga para ship | +| Debts bloqueado (lock / ambiguidade) | **Pare** — não ship | +| Ship: feature atrás de main | Não deve ocorrer se Passo 1 OK; se ocorrer, re-rode rebase antes de merge | +| Ship: branch é `main` | Pare (guard da filha) | Commit das mudanças de roadmap/planos feitas no Passo 2 entra no **mesmo** `ship-to-main` (Passo 2 da filha — commit all). diff --git a/.cursor/skills/engineering-audit/SKILL.md b/.cursor/skills/engineering-audit/SKILL.md new file mode 100644 index 00000000..673470da --- /dev/null +++ b/.cursor/skills/engineering-audit/SKILL.md @@ -0,0 +1,155 @@ +--- +name: engineering-audit +description: Runs a Pass-style engineering audit of the Teqo codebase — code smells, drift from documented patterns, and consolidation of near-duplicate components/hooks/functions/modules — producing a findings ledger and a remediation plan (read-only on code; docs only after sign-off). Use when the user asks for an engineering audit, code-smell sweep, architecture drift check, deduplication/consolidation pass, "Pass 3", "auditoria de engenharia", "varredura de code smells", "consolidação de duplicações", or to plan fixes against the repo's highest engineering standards. +--- + +# Engineering Audit (Pass N) + +Read-only audit that sweeps the Teqo codebase for code smells, drift from documented patterns, and consolidation opportunities, then produces a findings ledger and a remediation plan. **No fixes in the audit run** — deliverables are updated docs, presented for sign-off before writing. + +Method: the engineering skills this repo follows (improve-code-quality, clean-code, refactoring-patterns, software-design-philosophy, pragmatic-programmer, working-with-legacy-code, remove-technical-debt), specialized to Teqo's standards and history. The pass number is the next one after the last in `docs/IMPROVE-CODE-QUALITY-PLAN.md`. + +## Checklist + +``` +- [ ] 0. Load the canon and the history (before judging anything) +- [ ] 1. Hotspot map (churn × size × mechanical gates) — aim the sweep +- [ ] 2. Smell sweep (Fowler families + Teqo-specific), parallelized per area +- [ ] 3. Consolidation hunt (equivalence classes; name the duplicated KNOWLEDGE) +- [ ] 4. Triage (severity P0–P3; verify open ledger rows) +- [ ] 4b. Recurrence prevention: classify a deterministic guard for every finding class +- [ ] 5. Draft the three artifacts; sign-off; then write and stop +``` + +## Ground rules (non-negotiable) + +1. Read-only on `src/`, `tests/`, `scripts/`. The only writes are the step-5 artifacts, after sign-off. +2. Gate commands bare, never piped (`pnpm test | tail` swallows the exit code). +3. Never `knip --fix` blind — verify with `git grep -w ` (knip cannot load `payload.config.ts`; ledgered P3). +4. Production is live Neon with real PII. Local DB only, `teqo_test` for tests. The audit needs no DB writes. +5. Every claim gets a number (lines, exports, call sites, ms, kB). "Rejected by measurement" is an acceptable outcome for any hypothesis, including the ones below. + +## Step 0 — Load the canon + +Read, in this order: + +1. `.cursor/rules/engineering-standards.mdc` — gates, type honesty, client boundary, caching ladder, access control. +2. `.cursor/rules/codebase-map.mdc` — dependency direction, where things live, the list system, invariants. +3. `docs/ARCHITECTURE.md` — layers, bounded contexts, decision log. +4. `AGENTS.md` — operational rules + "Recently resolved" history (Pass 1, Pass 2, every post-Pass-2 delivery's /simplify findings). +5. `docs/IMPROVE-CODE-QUALITY-PLAN.md` — what the earlier passes already swept. Don't re-register what they fixed; verify fixes held. +6. `docs/TECH-DEBT.md` — open ledger. New findings de-dup against it; verify each open row still exists, close stale ones. +7. `docs/TESTING.md` — safety-net map: what is pinned, where the gaps are. +8. `docs/plans/escala-dry-pos-*.md` — per-delivery debt registrations. +9. Consolidation precedents (the quality bar for step 3): `runStaffEntityMutation` (`src/utilities/campaignEntityActions.ts` — dedup by POLICY, not generic plumbing), `runCampaignFormAction`/`runCampaignRedirectFormAction` (`src/utilities/campaignFormActionError.ts`), `CampaignTable` columns-as-data (`src/components/campaign/shared/CampaignTable.tsx`), `RelationChipCell` (B37: one engine, two thin domain wrappers), `useCampaignCellAutosave` + `campaignJsonMutationRoute` (B32+: wrapper > helper — a helper is a line someone can forget), `relationMembershipDelta.ts` (algorithm once, cap as data, three one-line wrappers). +10. **Rejected-with-reason** (never re-propose): set-with-floor generic form (B37); `CampaignCellEditOverlay` Popover branch for comboboxes (dialog can't be an ARIA 1.2 combobox popup); `maxItems` on dobradinhas "for parity" (invents a rule); catalog-out-of-browser vs payload-minimal chips (B34+ — fixes oppose each other; chosen: payload-minimal); `src/domains/` + ports-and-adapters (Pass 2 D1 NO-GO). + +## Step 1 — Hotspot map + +- Churn: `git log --since="3 months ago" --name-only --format= | sort | uniq -c | sort -rn | head -40`. Three-axis heuristic: changing next × high churn × core domain. +- Size: largest modules and widest interfaces (exports per module). +- Static gates: `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm exec knip`, `pnpm check:cycles` — record baseline. Pre-existing reds (e.g. the ledger's P1 e2e row) are noted as pre-existing, not the audit's. + +## Step 2 — Smell sweep + +Sweep `src/lib`, `src/utilities` (+`access/`), `src/components/campaign`, `src/app/(campaign)`, `src/app/(frontend)`, `src/collections`, `src/globals`, `scripts/`, `tests/`. Parallelize with subagents per area; each returns findings in the step-4 row format. + +**Fowler families, Teqo reading:** + +- Bloaters: god modules (>400 lines or >20 exports needs justification), long functions (>~60 lines), long parameter lists (>4 → options object), god cells/pages. +- Dispensables: dead code, commented-out code, speculative generality (config params nobody passes — a decision the author declined to make), pass-through wrappers. +- Change preventers: divergent change (one file every feature touches), shotgun surgery (one concept scattered — e.g. a cap message as a bare literal in a throw AND in two allowlists; B32+/B37 found exactly this). +- Couplers: feature envy, message chains, middle men, inappropriate intimacy. +- Primitive obsession: stringly-typed slugs/ids/enums where a contract type exists; magic numbers without a named constant next to its policy. + +**Teqo-specific smells:** + +- Local API with `user` but no `overrideAccess: false`; admin bypass without the justifying comment. +- Multi-collection write without `withPayloadTransaction` / `req: { transactionID }`. +- Client component importing a server data module for VALUES (only `import type` or contract modules); URL serializers reaching the browser (B14's 21 kB lesson); static catalogs in RSC payloads without measurement. +- Loader missing `import 'server-only'`; `lib/` importing from `utilities/`; pure helpers stranded in `utilities/`; React components in `utilities/`. +- `as never` (banned), unjustified casts, types declared twice (W4e single-source rule), `any`. +- RSC payload bloat: whole Payload docs over the wire instead of selected view models. +- New lists not on the W1 list system (raw `ui/Table` outside documented exceptions); JSON POST routes not on `campaignJsonMutationRoute`; formActions ladders not on the shared wrappers beyond documented exceptions. +- Vocabulary: banned terms (`actionPlan`, Praça/Núcleo) outside migrations; pt-BR identifiers; English in user-visible copy. +- Error messages as bare literals matched by exact string in `mapCampaignFormActionError` — must be constants (B32+ lesson). +- State captured by the render that scheduled the work (B34's recurring bug class): closures over stale state in autosave/optimistic paths; functional updaters whose FALLBACK reads stale render state. +- Live-region mistakes (B32+): regions inside what unmounts on close; polite regions mounted unconditionally at scale. +- Effects doing derived-state work; state lifted higher than its consumers; providers wrapping non-consumers. +- Caching-ladder violations: live 2026 data under `unstable_cache` without write-path invalidation; auth inside a cached core; artifacts computed at build time. + +**Legacy-code discipline:** a bug found while auditing gets RECORDED, not fixed. Pin actual behavior before proposing its consolidation. + +**Existing guards are findings too.** For every convention spec (`codebaseConventions.unit.spec.ts`), ESLint restriction, and knip/madge config, ask how it can be dodged: accent variants the regex misses, filename shapes the glob doesn't reach, import kinds the sweep doesn't mark, props invisible to knip, modules born after the allowlist. A dodgeable guard is a finding in its own row. + +## Step 3 — Consolidation hunt (core deliverable) + +Merge components, hooks, functions, and modules implementing the SAME functionality with different data — even when not textually identical. Small result changes are acceptable when functionality is equivalent; every proposal names its **behavior delta** explicitly. + +**Anti-DRY trap:** DRY is about KNOWLEDGE, not text. Two identical-looking blocks serving different business rules are NOT duplication. For every candidate pair, write down *what single piece of knowledge/rule/policy both encode*. Can't name it → record "look-alike, not duplication" and move on. + +**Equivalence classes (ranked by expected yield):** + +1. Same algorithm, different domain data → parameterize the data (`relationMembershipDelta` precedent). +2. Same interaction machine, different entity → one engine + thin domain wrappers (`RelationChipCell` precedent). +3. Same state machine, different field → shared hook (`useCampaignCellAutosave` precedent). +4. Same route/action skeleton, different collection → policy wrapper with typed caller (`runStaffEntityMutation` — NEVER a generic slug-union factory; typed mutation stays in the caller). +5. Same visual pattern, different content → slots/children from the server, not flag props. +6. Same predicate shape, different role/field → policy-as-data or named aliases (`canUpdateX = canReadX` is a deliberate declaration). + +**Fertile grounds (history):** `components/campaign/**/*Cell.tsx`/`*Control.tsx` (remaining twins — ledger B34+ F2: `LeadershipStateDeputyRelationCell` vs shared chip cell, ~165 duplicated lines); `campanha/actions/*.ts` twins beyond current wrappers; `utilities/*Data.ts`/`*PageData.ts` same-shaped read assemblies (where-builders, facet loaders, scope reads — E11's own-read-vs-shared-scope is the documented example); `utilities/access/*.ts` same constraint per domain; `lib/` parse/format/label twins, same-shaped zod schemas, constants under two names; `components/campaign/shared/` overlapping pieces; `tests/` cloned specs (`describe.each` precedent) and helper twins; `scripts/` seed/build twins (B5 precedent: `scripts/lib/`). + +**Generalization tactics, in order:** (1) parameterize difference as DATA (columns/caps/copy); (2) compose via slots/children from the server; (3) inject the variant as a narrow concrete-typed callback; (4) named wrapper over a shared core (policy in the wrapper; core stays dumb). Forbidden/last-resort: boolean-flag multiplication; generics forcing `as never`/`as unknown` (type honesty outranks reuse); "universal" abstraction with more configuration than code; merging things whose only commonality is shape. + +**Abstraction gate:** 3+ call sites OR a policy worth naming. With exactly 2: delete one, inline, or ledger the pair WITH A TRIGGER ("3rd call site merges these") — the B34+/B37 pattern. Merges must REDUCE total interface count (anti-classitis). + +**Behavior-delta protocol:** allowed — consolidating where outputs differ in small ways (copy, ordering, debounce timings, class names), listed per item; required — existing pins are the characterization net, updated DELIBERATELY in the same delivery with every changed assertion listed (a silently changed pin is a defect); not allowed — URL contracts (frozen, B18), DB schema (migration = separate delivery), public API shapes, Consent/LGPD fail-closed behavior, without a named separately-approved item. + +## Step 4 — Triage + +Each finding a ledger row: ID, area, smell/type, evidence (file:line + measurement), pattern/decision violated, proposed fix/consolidation, behavior delta, blast radius, pins needed, recurrence-guard class (step 4b), effort (S/M/L), severity: + +- **P0** correctness/security (access control, transactions, consent, type honesty masking errors) +- **P1** active harm (permanently red gate, measured perf harm, duplication causing divergent behavior) +- **P2** smell/drift with real cost +- **P3** polish + +Prioritize: changing next × high churn × core domain. Verify every open TECH-DEBT row: still true? Close stale with evidence. + +## Step 4b — Recurrence prevention (deterministic guards) + +A finding is not fully planned until its **recurrence prevention** is classified. The question for every finding class: "what deterministic mechanism makes this smell impossible — or at least a build failure — next time?" Rank the mechanisms by determinism: + +1. **Type** — the bad state becomes unrepresentable (single-source the type/value; a required prop; a schema transform). Strongest: it cannot compile. +2. **ESLint** (`no-restricted-syntax`, `no-restricted-imports`) — per-file, instant feedback. Precedents: the `as never` ban; the `src/lib` boundary. +3. **Convention unit spec** (`tests/unit/codebaseConventions.unit.spec.ts`, table-driven sweeps of `src`/`tests`/`scripts`) — structural policies. Precedents: vocabulary guard (C13), formActions guard (W4d), `campaignJsonMutationRoute` guard (B32+), `server-only` sweep. +4. **CI static analysis** — knip (dead code), madge (cycles). Already at error level. +5. **Behavioral pin** — a unit/int spec locking the contract ("every curated alias resolves in every consumer"; "dossier aggregate === list aggregate"). +6. **Doc/convention** — rules files, codebase-map, rejected-with-reason lists. Last resort, and it must say so: judgment-only findings (abstraction-gate calls, bundle trade-offs) live here, declared as such. + +Rules: + +- If a guard is feasible, it ships **in the same delivery as the fix** — never as a follow-up. A guard that lands later is a guard that never lands. +- If an existing guard covers the class but is dodgeable, hardening it is its own plan item. +- If only judgment prevents recurrence, register the convention explicitly (rules/codebase-map) and mark the finding "judgment-only" — do not pretend a doc is a guard. +- Prefer one guard per class over N per-instance pins: the guard is what makes the fix the _last_ time the smell is fixed. + +## Step 5 — Artifacts (draft, then sign-off) + +1. `docs/IMPROVE-CODE-QUALITY-PLAN.md` — new Pass section (context, audit headlines with numbers, workstreams, decisions), matching earlier-pass format. +2. `docs/TECH-DEBT.md` — new rows in the same tables/columns, marked `open — Pass N`; stale rows closed with evidence. +3. `docs/plans/entrega-engenharia-pN.md` (pt-BR, like the other plans) — workstreams ordered P0→P1→P2; each item: goal, evidence, target shape, migration path (Branch by Abstraction / Parallel Change for wide merges), pins to write first, full gate, rollback. Items too big for one delivery → roadmap per the `roadmap-item` convention. Every workstream item declares its **Guarda determinística** (class 1–6, step 4b), and the plan carries a **guard map** section: new guards shipped / existing guards hardened / judgment-only residue with the convention that stands in. +4. Present ALL THREE for sign-off before writing. Then write and stop. + +## Execution rules for the eventual implementation (record in the plan) + +- One consolidation per delivery; full gate per delivery: `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` — bare — plus Aikido on edited files. +- Structure-only commits never mix with behavior changes; red test mid-refactor = revert, not debug. +- Frozen migrations never edited; schema change = `pnpm migrate:create`. +- The rejected-with-reason list binds implementation too. +- Leftovers → ledger via `capture-review-debts`; nothing lives only in chat. +- No item closes without its prevention class recorded in the PR; a guard ships with the fix, not "after". + +## Done when + +Canon read; hotspot map with numbers; sweep complete with ledger rows; every consolidation candidate classified (merge now / register with trigger / look-alike-not-duplication — duplicated KNOWLEDGE named for each merge); every open ledger row verified; every finding class carries a recurrence-guard classification and the plan carries the guard map; three artifacts presented for sign-off. diff --git a/.cursor/skills/implement-roadmap-item/SKILL.md b/.cursor/skills/implement-roadmap-item/SKILL.md index 97f7e9ea..47725dd6 100644 --- a/.cursor/skills/implement-roadmap-item/SKILL.md +++ b/.cursor/skills/implement-roadmap-item/SKILL.md @@ -9,6 +9,8 @@ Esta skill transforma um item do `docs/roadmap.md` em um plano de implementaçã Superfícies de UI deste item passam pelo fluxo **`/impeccable`** (skill anexada ou em `~/.claude/skills/impeccable`): **shape → craft → critique → polish** (obrigatório em B/C/D), alinhado a `PRODUCT.md` / `DESIGN.md` e aos design-refs do roadmap. **`harden` e `optimize` não entram no pipeline fixo** — só sob gatilho (Passo 8). Itens só de schema/server/utilitário **não** inventam UI via Impeccable — veja Passo 8. +**Modelo × effort:** antes de executar (Passo 10) ou despachar subagentes, aplique a skill `model-selection` — a classe do item (feature delivery, /simplify, migration, docs) define o modelo recomendado e o effort; subagentes já saem no slug certo via parâmetro `model` da tool Task. + **Qualidade de decisão:** ao auditar e fatiar, aplique [decision-quality.md](../roadmap-item/decision-quality.md) — caro vs barato, Opções+Recomendação+rejeitadas, appetite do plano, rabbit holes, depth/classitis, tracer bullet cedo. Sem jornada de `design-code-architecture`. **Dados → decisão → apresentação:** ao auditar o plano e antes de craft de UI com métricas/mapas/listas analíticas, aplique [data-presentation.md](../roadmap-item/data-presentation.md). Se o plano omite a seção e o item claramente apresenta dados → marque **defasado** e complete no Passo 7. A forma escolhida (número / tabela / mapa / chart) é input do Impeccable — não trocar no polish sem reabrir a decisão. diff --git a/.cursor/skills/model-selection/SKILL.md b/.cursor/skills/model-selection/SKILL.md new file mode 100644 index 00000000..f1fb14e5 --- /dev/null +++ b/.cursor/skills/model-selection/SKILL.md @@ -0,0 +1,69 @@ +--- +name: model-selection +description: >- + Escolhe o modelo (e o nível de raciocínio/effort) com melhor custo/valor + para cada classe de tarefa do Teqo, e despacha subagentes já no modelo certo + via o parâmetro `model` da tool Task. Usar no início de trabalho de + implementação, ao despachar subagentes, antes de passes de /simplify ou + auditoria, ou quando o usuário perguntar "qual modelo usar", "modelo mais + barato para X", "troca o modelo", "vale a pena effort high". +--- + +# Seleção de modelo × effort por classe de tarefa + +Dois eixos independentes, decididos nesta ordem: **(1) classe da tarefa → modelo**, **(2) custo de uma falha → effort**. Preço e contexto compram capacidade; effort compra deliberação (tokens de thinking, cobrados como output). Esforço escala com **quanto custa errar**, não com o tamanho da tarefa. + +## Tabela canônica (atualizada 2026-07-29 — revalidar preços em cursor.com/docs/models-and-pricing antes de citar valores) + +| Classe de tarefa | Modelo (1ª escolha) | Fallback metered | Effort | +| ----------------------------------------------------------------------------------------------------- | ------------------- | ---------------- | -------- | +| Feature delivery do roadmap (itens B/E/C, multi-arquivo, gates completos) | Composer 2.5 (pool) | Gemini 3.1 Pro | Medium | +| Passes de /simplify, auditoria de plano, review debts (bugs sutis: closures, races, drift de strings) | Kimi K3 (1M ctx) | Gemini 3.1 Pro | **High** | +| Migrations, access control / RBAC, Consent, segurança (blast radius alto, história congelada) | Kimi K3 | Gemini 3.1 Pro | **High** | +| Fixes pequenos e localizados (um arquivo, sintoma conhecido, repair de teste) | Kimi K2.7 | Haiku 4.5 | Medium | +| Ciclos de discovery / research (compêndios, personas, relatórios longos em pt-BR — write-heavy) | GLM 5.2 | Gemini 3.6 Flash | Medium | +| Docs/bookkeeping (planos, TECH-DEBT, débitos no roadmap), Prettier, merges, rebase, chores | Grok 4.5 (pool) | Kimi K2.7 | **Low** | +| Perf/infra (medição de bundle, waterfall, análise de queries) | Gemini 3.1 Pro | Composer 2.5 | Medium | +| Depuração travada ("horas sem progresso", bug intermitente com hipóteses esgotadas) | Kimi K3 | GPT-5.6 Luna | **Max** | + +## Regras + +1. **Pool primeiro.** Composer 2.5 e Grok 4.5 saem do pool "Cursor Models" (uso incluído generoso) — ~70% do trabalho deste repo é rotina e não deveria tocar o pool metered ("Other Models", cobrado a preço de API). +2. **Kimi K3 é cirúrgico, não default.** Custa 3× o K2.7 no input e ~4× no output. Reservar para as três classes onde uma falha perdida vira bug de produção ou migration congelada errada. O 1M de contexto sem sobretaxa é o diferencial para auditorias repo-wide. +3. **Effort default = Medium.** Subir para High só nas classes marcadas; Low em docs/formatação/merges; Max é exceção de depuração, nunca rotina. Em tarefa fácil, effort alto só reescreve o óbvio com tokens mais caros. +4. **Nunca economizar em:** migrations (história congelada, aplicam em produção no build), access control (`overrideAccess: false`), Consent/LGPD, e passes de /simplify — o histórico do repo mostra que é neles que os P0/P1 sutis aparecem. +5. **Metered barato ≠ grátis:** K2.7 ($0.95/$4) e Haiku 4.5 ($1/$5) consomem o pool "Other Models"; se a tarefa cabe num pool model, pool vence sempre. + +## Como aplicar o modelo + +- **Subagentes (automático):** a tool `Task` aceita `model`. Despache já no slug certo — não rode o subagente no modelo herdado e sugira troca depois: + - `composer-2.5` / `composer-2.5-fast` — features e rotina + - `cursor-grok-4.5-high` — docs, chores, exploração + - `gemini-3.1-pro` — fallback metered forte / perf + - `gemini-3.6-flash-high` — discovery write-heavy (fallback) + - `glm-5.2-high` — discovery/research + - `kimi-k2.7-code` — fixes pequenos + - `kimi-k3-low` — auditorias/simplify/migrations (única variante K3 disponível para subagentes; o raciocínio extra vem do modelo, não do effort knob aqui) + - `claude-4.5-haiku-thinking` / `gpt-5.6-luna-medium` — fallbacks de fix pequeno + - `inherit` só quando a classe do subagente é a mesma da sessão. +- **Sessão principal (sugestão):** o agente não troca o próprio modelo. Ao classificar a tarefa no início do trabalho, se o modelo atual não for o da tabela, diga em uma linha: _"Esta tarefa é classe X — o melhor custo/valor é **Y** (effort Z); considere trocar no seletor de modelo."_ Não repita o aviso na mesma sessão. +- **Usuário pediu modelo específico:** a escolha dele vence a tabela; use-a só para avisar uma vez se houver desperdício claro (ex.: K3 numa formatação Prettier). + +## Gates por fluxo (onde a decisão de modelo entra no loop) + +- **`/simplify` (comando built-in, não editável):** seus três subagentes de review **herdam o modelo da sessão pai** — não há como despachá-los em outro modelo. Portanto a decisão acontece **antes** de invocar `/simplify`: se a sessão não estiver em Kimi K3 (effort High) ou Gemini 3.1 Pro, avise uma vez e sugira a troca — _"passes de /simplify são onde este repo acha os P0/P1 sutis; considere rodar em K3 High."_ Se o usuário optar por seguir no modelo barato, aceite — é escolha informada, não erro. +- **`ship-to-main` / `close-delivery`:** têm Passo 0 com gate declaratório — param o fluxo se a sessão estiver em metered premium. São classe docs/chores; nunca justificam K3/Pro/GLM. +- **`implement-roadmap-item`:** a classe pode mudar **por fase** — fases de schema/migration são High-effort (K3), fases de documentação da sessão são Low (pool model). Sessões longas: vale sugerir troca entre a fase de engenharia e a fase final de docs. +- **`suggest-next-roadmap-items`:** leitura + ranqueamento — classe docs/análise; pool model (Grok 4.5) ou metered barato bastam. A recomendação de modelo que ele emite por item é para a **worktree de implementação**, não para a sessão de sugestão. +- **Discovery cycles (`docs/research/`):** write-heavy pt-BR — GLM 5.2 ou Gemini 3.6 Flash, Medium. + +## Quando o effort faz diferença (critério de decisão) + +Suba um degrau de effort quando **duas ou mais** forem verdade: + +- a falha é silenciosa (sem teste que a pegue — ex.: drift de string em `safeMessages`, race otimista); +- a verificação exige rastrear um valor por 3+ frames/requests/renders; +- o diff toca migration, access, ou hook com `req`/transação; +- a tarefa é "provar ausência" (auditoria, knip, ciclos) e não "produzir presença". + +Desça um degrau quando a saída é mecânica (formatar, renomear, registrar débito, resolver merge trivial) ou quando já existe gate que pega o erro (tsc/lint/teste pinado cobre exatamente o risco). diff --git a/.cursor/skills/ship-to-main/SKILL.md b/.cursor/skills/ship-to-main/SKILL.md index bbae93e6..05b13676 100644 --- a/.cursor/skills/ship-to-main/SKILL.md +++ b/.cursor/skills/ship-to-main/SKILL.md @@ -21,6 +21,7 @@ Etapa final do fluxo de entrega Teqo em worktree: **commit all → push feature ## Checklist ``` +- [ ] 0. Gate de modelo (skill model-selection: pool model; pare se metered premium) - [ ] 1. Detectar ambiente (branch, MAIN_ROOT, worktree path) - [ ] 2. Commit all (se houver mudanças; seguir protocolo de commit) - [ ] 3. Push da feature branch @@ -29,6 +30,13 @@ Etapa final do fluxo de entrega Teqo em worktree: **commit all → push feature - [ ] 6. Reportar URLs/SHAs e avisar se a sessão Cursor ficou em path morto ``` +## Passo 0 — Gate de modelo + +Esta skill é classe **docs/chores** (git mecânico, sem decisão de código) — skill `model-selection`: o modelo certo é **Grok 4.5 ou Composer 2.5 (pool Cursor Models)**, effort low. Skills não detectam o modelo ativo programaticamente, então o gate é declaratório: + +- Se esta sessão estiver rodando em **modelo metered premium** (Kimi K3, Gemini 3.1 Pro, GLM 5.2, GPT-5.6, Haiku), **PARE antes do Passo 1** e diga: _"ship-to-main é trabalho mecânico de git — o melhor custo/valor é Grok 4.5 / Composer 2.5 (pool). Troque no seletor de modelo, ou confirme que aceita o custo metered."_ Só continue após confirmação explícita do usuário. +- Se já estiver em pool model ou metered barato (Kimi K2.7), siga sem mencionar. + ## Passo 1 — Detectar ambiente ```bash diff --git a/.cursor/skills/suggest-next-roadmap-items/SKILL.md b/.cursor/skills/suggest-next-roadmap-items/SKILL.md index 0ca90998..a77dc2ac 100644 --- a/.cursor/skills/suggest-next-roadmap-items/SKILL.md +++ b/.cursor/skills/suggest-next-roadmap-items/SKILL.md @@ -17,16 +17,17 @@ Esta skill **só recomenda** — não implementa, não edita o roadmap e não cr **Divisão com as skills irmãs:** -| Skill | Papel | -| ---------------------------- | ------------------------------------------------------ | -| `compile-roadmap` | Enxugar roadmap: resumir feito, destacar abertos | +| Skill | Papel | +| ---------------------------- | -------------------------------------------------------------------------- | +| `compile-roadmap` | Enxugar roadmap: resumir feito, destacar abertos | | `suggest-next-roadmap-items` | **Escolher o próximo + todos os paralelos** entre o que o roadmap já lista | -| `roadmap-item` | Registrar ideia nova / criar plano | -| `implement-roadmap-item` | Auditar plano + implementar o ID escolhido | -| `rebase-on-main` | Após simplify: fetch + rebase em main + conflitos | -| `capture-review-debts` | Triagear débitos de `/simplify` / critique | -| `ship-to-main` | Commit + push + merge main + apagar worktree | -| `close-delivery` | Orquestra rebase + debts (auto-confirm) + ship | +| `roadmap-item` | Registrar ideia nova / criar plano | +| `implement-roadmap-item` | Auditar plano + implementar o ID escolhido | +| `rebase-on-main` | Após simplify: fetch + rebase em main + conflitos | +| `capture-review-debts` | Triagear débitos de `/simplify` / critique | +| `ship-to-main` | Commit + push + merge main + apagar worktree | +| `close-delivery` | Orquestra rebase + debts (auto-confirm) + ship | +| `model-selection` | Modelo × effort por classe de tarefa (custo/valor) | ## Checklist @@ -132,8 +133,8 @@ Um candidato entra na lista paralela quando passa **nos três testes contra o pr **Superfícies serializadoras do Teqo (no máximo 1 item ativo por linha em qualquer worktree simultânea):** -| Superfície | Por que serializa | -| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Superfície | Por que serializa | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `src/migrations/` (SQL + `.json` snapshot + `index.ts`) e o `src/payload-types.ts` que a segue | A cadeia de snapshots é linear: dois itens com migration não se resolvem por merge textual, um tem que refazer | | Artefato TSE (`src/lib/electionAggregates/`, `build:election-aggregates`, orçamento de bytes) | Arquivo gerado grande + teste de tamanho; dois geradores em paralelo colidem inteiros | | Seams do sistema de listas (`CampaignTable`, `campaignListUrl`, shells de `campaign/shared`) | Quem **altera** a seam serializa; quem só **consome** pode ir junto | @@ -164,7 +165,8 @@ Formato obrigatório da resposta (conciso; sem reescrever o roadmap): - Plano: `docs/plans/…` - Dependências: ok / suave pendente: … - Impeccable: A|B|C|D -- Próximo passo: skill `implement-roadmap-item` neste ID +- Modelo (skill `model-selection`): · effort — classe da tarefa em meia frase +- Próximo passo: skill `implement-roadmap-item` neste ID (na worktree do item, já no modelo acima) ## Em paralelo com ele (todos, por prioridade) diff --git a/.prettierignore b/.prettierignore index 04fe99c7..420c2671 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ playwright-report/ test-results/ data/ pnpm-lock.yaml +.impeccable/hook.cache.json # Generated files (owned by their generators, not by Prettier) src/payload-types.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e8993b31..dac065af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,7 @@ flowchart LR ``` - **`src/lib/`** — lógica pura e dados estáticos, importável de client e server: catálogos (`municipalityCatalog`, `bahiaTerritories`, geometrias TopoJSON), schemas zod (`lib/schemas/*`), helpers puros (`phone`, `slug`, `wordStartFilter`, `voteEstimate`, `electionFormat`, `voteTrend`), constantes client-safe (`campaignConsentKeys`, `campaignRoles`), artefato TSE (`electionAggregates/` + `bahiaElectionAggregates.ts`). `lib/` **nunca importa de `utilities/`** (as 9 inversões históricas foram zeradas no Pass 2 W2). -- **`src/utilities/`** — código acoplado a Payload/Next. Loaders que tocam banco ou `unstable_cache` são marcados **`import 'server-only'`** na primeira linha (21 marcados no Pass 2). Módulos de UI-helper client-safe (ex.: `municipalityListUrl`, `supporterUi`) ficam aqui sem a marca por importarem apenas TIPOS do Payload. +- **`src/utilities/`** — código acoplado a Payload/Next. Loaders que tocam banco ou `unstable_cache` são marcados **`import 'server-only'`** na primeira linha (21 marcados no Pass 2). Módulos de UI-helper client-safe (ex.: `municipalityListUrl`, `supporterUi`) ficam aqui sem a marca por importarem apenas TIPOS do Payload. **Subpastas de domínio (P3-J):** `access/` (RBAC), `municipality/`, `supporter/`, `territory/`, `leadership/`, `visit/`, `webauthn/` — imports diretos sem barrels. O top-level é pinado por convenção programática: um módulo novo top-level quebra o build até ser registrado (o gatilho D1 do Pass 2 codificado — 3 módulos novos de um domínio no mês ⇒ subpasta). - **`src/collections/` + `src/globals/`** — o modelo de domínio É o Payload. Toda collection declara `access` explicitamente; hooks passam `req` (transação) — ver `AGENTS.md` e `.cursor/rules/security-critical.mdc`. - **`src/components/campaign/`** — subpastas por domínio (Pass 2 W2): `municipality/ activity/ supporter/ leadership/ advisor/ demand/ organization/ stateDeputy/ votePledge/ invite/ map/ dashboard/ auth/ shell/ shared/`. `shared/` é o sistema transversal (listas, tabela, comboboxes); `shell/` é o chrome do app (sidebar, nav, PWA). - **Atravessando a fronteira client:** um client component nunca importa um módulo de dados de servidor por VALOR. Tipos/constantes compartilhados vivem num módulo de contrato client-safe — precedentes: `municipalityMapContract.ts` (mapa), `votePledgeViews.ts` (matemática de pledges; loaders em `votePledgeData.ts` são `server-only`), `lib/campaignRoles.ts` (predicados de role para a sidebar — a fonte da qual `utilities/access/shared.ts` deriva). `import type` é sempre seguro. diff --git a/docs/IMPROVE-CODE-QUALITY-PLAN.md b/docs/IMPROVE-CODE-QUALITY-PLAN.md index 9bf0a0a5..a93b1907 100644 --- a/docs/IMPROVE-CODE-QUALITY-PLAN.md +++ b/docs/IMPROVE-CODE-QUALITY-PLAN.md @@ -1,6 +1,6 @@ # Improve Code Quality Plan -Two passes so far: **Pass 1** (2026-07-23/24, phases 0–6 below, all done) and **Pass 2** (started 2026-07-25, see [Pass 2](#pass-2--engineering-consolidation-2026-07-25)). +Three passes so far: **Pass 1** (2026-07-23/24, phases 0–6 below, all done), **Pass 2** (2026-07-25, see [Pass 2](#pass-2--engineering-consolidation-2026-07-25)) and **Pass 3** (2026-07-28, audit + plan, see [Pass 3](#pass-3--engineering-audit--consolidation-2026-07-28)). ## Context @@ -108,3 +108,57 @@ Statuses: pending · in-progress · awaiting-evidence · done · deferred: \ · skipped: \ + +## Pass 3 Decisions (signed off 2026-07-28) + +| ID | Decision | Rationale | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | Waves ordered correctness (A–C) → structure (D–J) → guards/docs (K–L); one consolidation per delivery | Correction before structure; each delivery independently gated and revertable | +| D2 | Behavior-delta protocol accepted | The owner's "results may change a bit" license made safe: every delta listed per item; existing pins are the characterization net, updated deliberately — never silently | +| D3 | 11 two-call-site consolidation candidates registered as defer+trigger, not merged | The repo's abstraction gate (3+ call sites or a policy worth naming); anti-classitis — merges must reduce interface count | +| D4 | Anti-DRY rule enforced: every merge names the duplicated knowledge; look-alikes serving different rules rejected | DRY is about knowledge, not text (pragmatic-programmer); the rejected list lives in the plan to prevent re-proposals | +| D5 | No route/collection/migration and no URL-contract change in this pass | B18 frozen contract; P3-F preserves it by construction (round-trip through each domain's own parser) | +| D6 | Prevention-first amendment (2026-07-28): every workstream item classifies its recurrence guard on the determinism ladder (type → ESLint → convention spec → CI analysis → behavioral pin → doc); new guards ship in the SAME delivery as the fix; existing guards' dodgeability is re-audited every pass | The audit itself found three dodgeable guards (accent-sensitive vocabulary regex, `next/*` value imports invisible to the `server-only` sweep, `*FormActions.ts` filename shape) — preventing recurrence is part of the fix, not a follow-up | + +## Next Actions + +- [ ] P3-A → P3-L as independent deliveries (full gate each; Aikido on edited files) +- [ ] Leftovers and new debts → ledger via `capture-review-debts`; oversized items → roadmap via `roadmap-item` diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index fe1abc16..8d8165fc 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -4,31 +4,48 @@ Ledger of known engineering debt. Items large enough to be scheduled work become ## Debt Ledger -| Item | Location | Type | Risk | Effort | Priority | Status | Ref | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b | -| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | -| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | | -| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | -| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 | -| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | -| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | | -| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 | -| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | | -| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | | -| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | | -| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed. **Widened 2026-07-28 (B17), same stash-and-compare method: it is not two tests but most of `campaignMunicipalities`** — five failed at one worker on the clean tree (`:56`, `:125`, `:292`, `:394`, `:446`) against four with the delivery applied, and the failing set is not stable between runs (`page.waitForURL` timeouts, a `getByLabel('Média')` strict-mode violation matching both the popover trigger and its input, a click whose target the bottom nav intercepts). Practical consequence for the next delivery: comparing the count of red tests against a stashed tree is the only way to read this spec — naming the two tests above is no longer enough | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:56,125,292,394,446` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`), widened 2026-07-28 (B17) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card. The strict-mode violation is a real ambiguity in the spec's own locator, fixable on its own | | -| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | -| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | | -| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | -| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 | -| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | -| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f | -| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | — | **closed 2026-07-28 (B34+)** — `memoizePerRequest` keys on the `req` object (`WeakMap`), not `req.context` (measured: 367 contexts, 1 `req`; int `campaignAccessRequestMemo` pins 1 `campaign_user` read per scoped `leadership` list at depth 1 and role downgrade on the next op). Role-mutation audit: `reloadUnrestrictedActor` bypasses the memo path; pins cover cross-actor isolation | | -| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ | -| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 | -| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | — | **closed 2026-07-28 (B34+ F1)** — index `{id,slug}`; `name`/`region` from `municipalityCatalog` (int pins slug+region vs catalog). RSC −21,9 kB raw/rota; First Load JS unchanged vs clean `main` (306/298/271 kB). Drawer `next/dynamic` deferred | B34+ ✓ | -| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 | +| Item | Location | Type | Risk | Effort | Priority | Status | Ref | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b | +| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | +| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | | +| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | +| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 | +| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | +| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (intentional LGPD path) | low | S | P3 | closed 2026-07-29 (P3-D): documented in-code — the delete is the staff-driven titular-removal flow, scope already proven by `assertCanManageSupporter` on the exact row; day-to-day deletes stay admin-only | | +| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 | +| E2E suite thin (**15 specs / 31 cases** measured 2026-07-28, Pass 3 — the "7 specs" count is stale; most campaign CRUD still int-only, so the substance holds) | `tests/e2e/` | coverage | medium | L | P2 | open | | +| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | | +| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | | +| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed. **Widened 2026-07-28 (B17), same stash-and-compare method: it is not two tests but most of `campaignMunicipalities`** — five failed at one worker on the clean tree (`:56`, `:125`, `:292`, `:394`, `:446`) against four with the delivery applied, and the failing set is not stable between runs (`page.waitForURL` timeouts, a `getByLabel('Média')` strict-mode violation matching both the popover trigger and its input, a click whose target the bottom nav intercepts). Practical consequence for the next delivery: comparing the count of red tests against a stashed tree is the only way to read this spec — naming the two tests above is no longer enough. **2026-07-28 (Pass 3): root cause measured** — unanchored municipality-name regex at `campaignMunicipalities.e2e.spec.ts:263` (**23/435 prefix collisions** in the catalog: Conde/Condeúba, Catu/Caturama, Laje ×3, Barra ×4); the `getByLabel('Média')` strict-mode violation and the hydration-race consent checkbox are unchanged; fixture drift measured — the e2e cleanup lacks E14's `engagementLevel`/`levelNote`/catalog-name reset and owns+deletes the leased `apoiador-cadastro` row. Fix scheduled as **P3-C** ([entrega-engenharia-p3.md](plans/entrega-engenharia-p3.md)) | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:56,125,292,394,446` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | closed 2026-07-29 (P3-C): full suite **31/31 green** at 2 workers. Root causes fixed — stale sort-summary copy (`déficit da meta`→`Cobertura`), `exact` trigger name vs appended status suffix (now anchored regex), option click not selecting (role=option + `toPass` hydration-retry loop, same class as `checkRadixWhenHydrated`), stretched-overlay click needing `force`, leader login using the advisor's password (each `createCampaignUser` mints its own), and 30 s test timeout → 60 s under cold-compile load. Guards: unanchored-template-regex ban in `codebaseConventions`, global `expect` 10 s | | +| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | +| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | | +| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | +| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 | +| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | +| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | closed 2026-07-28 (Pass 3 verify): W4f executed 2026-07-25; the remainder is the provisional Onda 0 consent texts (legal batch, D6) | Pass 2 W4f | +| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | — | **closed 2026-07-28 (B34+)** — `memoizePerRequest` keys on the `req` object (`WeakMap`), not `req.context` (measured: 367 contexts, 1 `req`; int `campaignAccessRequestMemo` pins 1 `campaign_user` read per scoped `leadership` list at depth 1 and role downgrade on the next op). Role-mutation audit: `reloadUnrestrictedActor` bypasses the memo path; pins cover cross-actor isolation | | +| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ | +| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | **closed 2026-07-28 (Pass 3 verify): resolved by B37 — the cell moved to `shared/` as a thin wrapper over `RelationChipCell` (chip/search/copy only; the ~165 duplicated lines no longer exist, and `ResizeObserver` lives in exactly one component).** Original status: open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 | +| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | — | **closed 2026-07-28 (B34+ F1)** — index `{id,slug}`; `name`/`region` from `municipalityCatalog` (int pins slug+region vs catalog). RSC −21,9 kB raw/rota; First Load JS unchanged vs clean `main` (306/298/271 kB). Drawer `next/dynamic` deferred | B34+ ✓ | +| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 | + +| `safeMessages` entries that can never match their thrown gate messages — exact-string matching in `mapCampaignFormActionError` collapses them to the generic error: "movem demandas", "registram custos", "registram estimativas" (thrown with "e o candidato", safelisted without) and escalated-demand decide (`'…Coordenador Geral ou Candidato.'` vs `'…Coordenador Geral.'`) — the last **user-reachable** (advisor deciding an escalated demanda gets the generic message). +1 dead entry ("podem abrir demandas", thrown nowhere). Same collapse class in copy: `podem gerenciar lideranças.` ×3, `podem gerenciar apoiadores.` ×3, `gerenciam organizações.` ×3, `anexam comprovantes.` ×2, `'Celular brasileiro inválido.'` ×3 | throws: `actions/demand.ts:63,98`, `actions/votePledge.ts:143`, `collections/CampaignDemand.ts:113`; safelists: `demandas/[slug]/formActions.ts:26,27,77`, `pledgeFormActions.ts:63`, `demandas/nova/formActions.ts:34` | correctness (message-only; writes still correctly refused) | medium — real refusal reasons never reach the UI | S | P2 | open — Pass 3 | P3-A | +| Petition signature flow trusts a client-posted `consentId` — written into both `signature` and `subscription` with no server-side resolution, while the page renders the text from `petition.form.consent`; a tampered request records consent to a different document than shown (LGPD provenance weakened, no leak). The WhatsApp sibling resolves by stable key, fail-closed (Pass 2 D3) | `src/app/(frontend)/actions/submitPetitionSignature.ts:9,40,49`; supplier `src/components/PetitionForm.tsx:55-63` | LGPD correctness | medium | S | P2 | open — Pass 3 | P3-B | +| E2E/int fixture drift: e2e cleanup lacks E14's `engagementLevel`/`levelNote`/catalog-name reset (int got it 2026-07-27); e2e proxy owns+deletes the shared leased-key consent row `apoiador-cadastro` and reimplements the lease machinery; 28 hand-written `campaignUser` blocks (~280 lines) where one builder serves; `campaignListUrl` contract pinned at 3 levels (shared spec + 5 domain re-pins); invite-origin double-pinned across suites (~160 pure lines paying int cost); 43 manual `fixtures.own()` redundant with the undocumented auto-ownership proxy | `tests/e2e/fixtures/campaignE2EFixtures.ts`, `tests/helpers/campaignFixtures.ts`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:12-53` | test-infra drift | medium — flaky/lying fixtures in the delivery gate | M | P2 | partially closed 2026-07-29 (P3-C): e2e cleanup reset aligned (name/engagementLevel/levelNote), leased `apoiador-cadastro` consent via `ensureLeasedConsent`, 28 hand-written `campaignUser` blocks → `fixtures.createCampaignUser` builder. Remaining open: `campaignListUrl` 3-level pinning, invite-origin double-pin, 43 manual `fixtures.own()` | P3-C | +| Advisor-scope RBAC fragment spelled 7× across 6 access modules + near-verbatim scoped-read prologue twins + memoized `getAccessible*Ids` machine triplicated (+ `getAccessibleContactIds` missing its siblings' `isCampaignUnrestricted → null` early return — safe today only by call order) + access micro-cluster (byte-identical advisor/leader branches in `canCreateSupporter`; cross-domain alias `allocationDecisions`→`municipalityUpdates`; deprecated `LeadershipConsentDescriptor` wrapper still with 4 call sites) | `src/utilities/access/votePledges.ts:46`, `demands.ts:42`, `municipalityUpdates.ts:40`, `supporters.ts:48`, `activities.ts:43`, `contacts.ts:29,44`, `leaderships.ts:41,73,114`, `municipalities.ts:140-169`, `leaderships.ts:86-129`, `contacts.ts:23-100` | duplication (policy spelled N times) | medium — a rule change must land in 7 spots | M | P2 | closed 2026-07-29 (P3-D): `advisorMunicipalityScopeWhere` + `resolveActorScopedRead` + `resolveAccessibleIds` in `access/shared.ts`; branches collapsed; `LeadershipConsentDescriptor` deleted (full `ConsentDescriptor` now); contacts gained the unrestricted early return; guard spec bans the fragment outside `access/shared.ts` (8-file allowlist); unit pin `accessShared.unit.spec.ts`; 807 unit + 483 int green. The `allocationDecisions`→`municipalityUpdates` alias was verified CORRECT (deliberate same-policy reuse, documented) and stays | P3-D | +| "Resolve display names by ids" spelled 7× (one already a type-losing slug-union → `Record`) with ~8 admin bypasses missing the mandatory justification comment (plus ~10 more across WebAuthn ceremony, import token, phone invariant, consent, invite, map/page data) | `campaignDashboardData.ts:57-74`, `leadershipData.ts:56-72`, `organizationData.ts:64-79`, `campaignDemandData.ts:67-133,250-259`, `stateDeputyData.ts:251-266`, `votePledgeData.ts:130-146`, `activityDetailPageData.ts:25-51`, `municipalityUpdatePageData.ts:86-99` | duplication + convention breach | low-medium | M | P2 | closed 2026-07-29 (P3-E): typed wrappers in `loadNamesByIds.ts` (bypass comment once; the `Record` union died); ratchet guard in `codebaseConventions` pins per-file undocumented-bypass counts (only shrinks); plan-named sites documented | P3-E | +| Pledge aggregate fold spelled twice — list vs dossier surfaces must never disagree (E9 had to add `lastPledgeAt` to both); staff scope-read proliferation ×3 sharing a 5-field base select (E11's own-read documented) | `votePledgeData.ts:53-107` vs `votePledgeViews.ts:80-108`; `campaignMunicipalityScope.ts:39-59`, `municipalityTriggers.ts:202-243`, `visitPlannerData.ts:162-188` | duplication | medium — two surfaces can silently diverge | M | P2 | closed 2026-07-29 (P3-E): `aggregatePledgesWhere` delegates the bucket fold to `aggregateMunicipalityPledgesFromRows` (int pin: dossiê === lista); `loadMunicipalityScope(payload, user, where, { extraSelect })` with extras in the React `cache()` key (unit pin: cache-key separation), adopted by `municipalityTriggers` — its own município read died | P3-E | +| `MUNICIPALITY_CATALOG_CACHE_TAG` lives beside a `next/cache` import (no `server-only`) and is imported by a pure module — latent bundle hazard of the B14/B34+ class; `electionCache.ts` (tag alone) is the correct precedent | `src/utilities/municipalityRevalidation.ts:1,11` + importers `revalidateRequest.ts:3`, `municipalityPortfolioIndex.ts:9` | boundary | low | S | P2 | closed 2026-07-29 (P3-E): tag moved to `municipalityCatalogCache.ts` (tag alone, `server-only`, the electionCache precedent); `municipalityRevalidation.ts` marked `server-only` | P3-E | +| List-URL machine family spelled per domain: sort-toggle ×4, sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4; plus `firstValue` ×3 spellings, `XNotFoundError` ×3, `parseRegionsParam`+`canonicalTerritoryBySearchValue` ×2 (bundle-constrained — can't join `campaignListUrl`) | `municipalityListUrl.ts:426,440,451,508`, `leadershipListUrl.ts:192,203,216`, `stateDeputyListUrl.ts:158,165,178,202`, `territoryListUrl.ts:130,138,150,189`, `detailTabUi.ts:8-10`, `municipalityUpdatePageData.ts:39-42` | duplication | medium | M | P2 | partially closed 2026-07-29 (P3-F): `createSortToggleHref` adopted ×4; `territoryRegionParam.ts` (byte-identical parser died ×2, bundle-constrained as planned); `createEntityNotFoundError` adopted ×3. **Remaining open (defer + trigger: 3rd sort dropdown or a 5th multi-filter):** sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4, `firstValue` private spellings ×3 | P3-F | +| Legacy filter shells `ActivityFilters`↔`SupporterFilters` duplicate ~50 lines each and bypass `useCampaignListFilterNavigation` (they lose its no-op navigation guard — an RSC round trip fires even when re-selecting the active value); `CampaignSearchForm` hand-serializes `?q=` past the canonical builders (latent: first future filter param silently stripped on search submit); search idiom split (4 lists debounce vs 3 explicit submit, no recorded decision) | `components/campaign/activity/ActivityFilters.tsx:80-146,185-213`, `components/campaign/supporter/SupporterFilters.tsx:109-146,156-227`, `components/campaign/shared/CampaignSearchForm.tsx:30-31,41-43` | duplication + latent bug | medium | M | P2 | closed 2026-07-29 (P3-F): `useCampaignFilterValues` (committed-href no-op guard) + `CampaignCollapsibleFilterPanel` extracted and adopted by both shells; `CampaignSearchForm.filterParams` carries the canonical serialization of active filters (organizações adopted — `?kind=` survives a search submit); search idiom unified on 1 s debounce everywhere (`SEARCH_DEBOUNCE_MS`), explicit submit kept as the immediate path in `CampaignSearchForm` — decision recorded here | P3-F | +| Scripts re-spell the CLI skeleton: `die` ×7, `sha256` ×5, download→cache→sha256 ×5 (~20 lines each), `writeJson/writeText` ×5 (the newest script copied the OLD form — live drift), `cacheDir` ×5, dotenv preamble ×6; **two municipality-name resolvers diverged** — the supporter/CSV path lacks the TSE alias table and rejects 4 of 5 curated spellings ("Camacã", "QUINJINGUE", "Santa Terezinha", "Muquém do São Francisco"); `LOCAL_HOSTS` ×2 diverged; one-shot `generate-remodel-municipalities-migration.mjs` left behind holding 2 allowlist entries | `scripts/seed-tse-results.mjs`, `build-bahia-geometries.mjs`, `build-municipality-zone-geometries.mjs`, `build-bahia-demographics.mjs`, `build-bahia-tse-city-codes.mjs`, `seed-posts.mjs`, `db-pull.mjs`; `lib/schemas/supporter.ts:19-28` vs `lib/electionResults.ts:120-184` | duplication + latent import bug | medium | M | P2 | closed 2026-07-29 (P3-H): `scripts/lib/cli.mjs` (`dieWithLabel`/`sha256Hex`/`loadCliEnv`/`ensureCachedDownload`/`writeRepoFile`/`LOCAL_HOSTS`/`isRemoteDbOverrideSet`) adopted by 10 scripts, artifacts proven byte-identical (only `accessed` dates moved, reverted); **latent bug fixed**: single fold `municipalityNameAliases.ts` — supporter import now accepts all 5 curated spellings, pinned for BOTH paths in `municipalityNameAliases.unit.spec.ts`; db-pull gained the `postgres` host (deliberate); one-shot deleted + 2 allowlist entries freed; CLI-skeleton ban guard (die/sha256/dotenv/LOCAL_HOSTS) | P3-H | +| **`utilities/` subfoldering trigger (Pass 2 D1) has FIRED:** 122 top-level modules (was 92); new in 30 days (`git --diff-filter=A`): municipality +21, supporter +10, territory +5, leadership +5, webauthn +5, visit +4 — 6 domains past "3rd new module in a month". Pure modules stranded in the layer: `campaignTime.ts` (140 lines, zero imports), `relationship.ts` (33 lines, zero imports) → `lib/` | `src/utilities/` | organization | low (path churn) | M | P2 | closed 2026-07-29 (P3-J): 41 modules moved — `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (+`loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/`; direct imports, no barrels; `campaignTime` + `relationship` → `lib/`; top-level of `src/utilities/` pinned in `codebaseConventions` (D1 trigger encoded — new top-level module fails the build unregistered); `codebase-map.mdc` + `ARCHITECTURE.md` updated in the same delivery | P3-J | +| `createdBy`/actor-stamp field duplicated verbatim across 5–7 collections + stamping hook ×3 (organic drift already: Supporter/Leadership grew `read:`, CampaignInvite is `required`); a field factory precedent exists (`voteEstimateScenarioFields`); `trimmedText` ×4 byte-identical in collection configs (pure helper outside `lib/`) | `CampaignDemand.ts:382-394`, `Organization.ts:121-133`, `StateDeputy.ts:105-117`, `AllocationDecision.ts:140-151`, `VotePledge.ts:211-222`, `Supporter.ts:214-227`, `Leadership.ts:222-234`; hooks `Organization.ts:54-62`, `StateDeputy.ts:54-62`, `Leadership.ts:63-69` | duplication | low | S | P3 | closed 2026-07-29 (P3-I): `campaignAuditFields.ts` (`systemStampedActorField` with `required`/`indexed`/`readAccess` data + `stampCampaignCreatedBy`) adopted by 9 collections — zero schema delta (`declaredBy` keeps `indexed: false`, no migration); `trimmedText` → `lib/text.ts` ×4; guard bans hand-spelled actor fields | P3-I | +| Route layer: W4d filename guard escapes — 7 hand-rolled form-action ladders in `actions/*.ts` invisible to the `*FormActions.ts` guard (3 expressible today with zero delta: profile avatar ×2, leaderSupporter); **convite-login exception stale** (not allowlisted; now expressible via `runCampaignRedirectFormAction`); `password.ts`/`auth.ts` genuinely bespoke but unregistered; staff-page prologue ×22 (~110 lines) with `return null` vs `redirect` divergence ×5; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`); `requireConsentByKey` called raw where the named wrapper exists (`leaderSupporter.ts:99-104`) | `actions/profile.ts:37-105,107-151`, `actions/leaderSupporter.ts:99-104,151-190`, `campanha/convite/[token]/formActions.ts:68-98`, 22 `(app)` pages | duplication + convention breach | low-medium | M | P3 | partially closed 2026-07-29 (P3-I): `requireCampaignPageActor({gate, redirectTo})` adopted by all 30 `(app)` pages — the `return null` ×5 divergence died (missing session now always redirects to login); guard bans `getCampaignUser()` in route pages. **Remaining open (defer + trigger: the next bespoke ladder or route schema):** 7 hand-rolled `actions/*.ts` ladders (3 expressible: profile avatar ×2, leaderSupporter), stale convite-login exception, password.ts/auth.ts registration in the W4d guard widening, `municipios/advisors/route.ts` omit/extend, `requireConsentByKey` raw call in leaderSupporter | P3-I | +| Convention guards dodgeable: banned-vocabulary regex `/praç|núcleo/i` misses unaccented `praca`/`nucleo` (the sibling "Plano de Ação" row was hardened accent-tolerant); `server-only` guard sweeps only `payload\|@payload-config` value imports — `next/cache`/`next/server` importers (`municipalityRevalidation.ts`, `campaignJsonMutationRoute.ts`) are server-bound and invisible; no mechanism stops a client component value-importing the three client-safe modules that drag the 623 KB TSE artifact (`municipalityTerritorialClass`, `municipalityPotential`, `territoryIntraCaptureBenchmark` — B13 avoided by discipline) | `tests/unit/codebaseConventions.unit.spec.ts:50,71-80` | tooling gap | low | S | P3 | closed 2026-07-29 (P3-K): vocabulary regex now `/pra[çc]\|n[úu]cleo/i` (2 genuine-data hits allowlisted); `server-only` sweep widened to `next/cache`/`next/server`/`next/headers` (`campaignJsonMutationRoute` marked); ESLint `no-restricted-imports` bars value-imports of the 3 artifact carriers + the artifact itself from `src/components/**` (type imports safe — measured: the 2 current consumers are type-only) | P3-K | +| Constants/types single-sourcing cluster: territorial-class union declared twice across the lib/utilities boundary (adding a class compiles green and mis-sorts); `DAY_MS` ×3 (one exported from `suggestionCatalog`'s public interface); `1_000_000` vote-count ceiling ×6 across two validation layers; password `8/128` + token `20/256` policies ×2; TSE baseline years mirrored (documented excuse dies with a leaf module); `formatBrazilianPhoneDisplay` near-twin (1 call site); `advisorMunicipalitiesBatchSchema` missing the dedup transform its 3 siblings have; `voteEstimate` formatters re-spell the pt-BR format without the E18 zero-fraction pin; `hasAnyEstimate` weaker twin of `hasAnyVoteEstimate` | `lib/territorialClassSortWeight.ts:10-13`, `lib/suggestionCatalog.ts:15-19,175`, `lib/engagementLevel.ts:97`, `utilities/municipalitySignal.ts:32`, `lib/formData.ts:123-133`, `lib/schemas/votePledge.ts:9,32`, `lib/schemas/invite.ts:11,39`, `lib/voteEstimate.ts:27,102-126`, `lib/schemas/advisor.ts:69-72` | shotgun surgery | low-medium | S each | P3 | closed 2026-07-29 (P3-K): territorial-class union → `lib/territorialClassAnchors.ts` (type error on new class now); `DAY_MS` → `lib/text.ts` (out of `suggestionCatalog`'s public interface); `MAX_VOTE_COUNT` in `lib/schemas/primitives.ts` ×6 folded; `campaignPasswordSchema` exported (invite delegates); `lib/electionYears.ts` leaf (mirror + documented excuse died); `formatBrazilianPhoneDisplay` delegates; batch-schema dedup transform (action's `[...new Set]` died); `formatElectionNumber` in the 3 `voteEstimate` formatters; `hasAnyEstimate` deleted | P3-K | +| Form-action feedback JSX at 15+ call sites with `aria-live` on only 5 — **10 error alerts silent to AT**; success→toast effect ×7; borderless inline-input class ×4 verbatim; deprecated `showLabels` prop with zero call sites (knip can't see props) | 15 forms incl. `LeadershipForm.tsx:162`, `DemandForm.tsx:152`, `MunicipalityUpdateForm.tsx:168`, `PledgeEstimateForm.tsx:74`; `VoteEstimateScenarioStrip.tsx:18,27,32` | a11y + duplication | medium (a11y) | S–M | P3 | closed 2026-07-29 (P3-G): `CampaignFormActionMessage` (aria-live on both variants + `formFieldErrors`) adopted by all 15+ sites — every alert now announces; `useCampaignFormSuccessToast` adopted ×7; `campaignInlineInputClassName` ×4; `showLabels` deleted; JSX ban guard + a11y pin (`campaignFormActionMessage.unit.spec.tsx`, unit include extended to `.tsx`) | P3-G | ## Smell Inventory diff --git a/docs/TESTING.md b/docs/TESTING.md index b181f57f..8164084d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -32,7 +32,7 @@ Modules the 2026-07 hardening phases touch, and where their behavior is pinned: ## Characterization Backlog -- [ ] `src/utilities/posts.ts` — dedicated spec for `isPostVisible` fail-closed matrix and cache wrappers. -- [ ] `actionPlanViewModels.ts` + other `actionPlan*` utilities (~1k lines) — `actionPlanUi.ts` pinned 2026-07-25 (Pass 2 W0); view models still untested directly. +- [ ] `src/utilities/posts.ts` — dedicated spec for `isPostVisible` fail-closed matrix and cache wrappers (confirmed 2026-07-28, Pass 3 audit). +- [ ] `activityViewModels.ts` (ex-`actionPlanViewModels`, renamed by C13) — `activityUi.ts` pinned 2026-07-25 (Pass 2 W0); view models still untested directly (confirmed 2026-07-28, Pass 3 audit). - [x] `campaignDemandData.ts` / `leadershipData.ts` / `organizationData.ts` / `stateDeputyData.ts` list loaders — smoked 2026-07-25 (`campaignEntityListData.int.spec.ts`, Pass 2 W0). - [x] Election aggregate artifact (Phase 2): snapshot test pinning artifact ↔ `municipalityCatalog` key consistency — delivered as `bahiaElectionAggregates.unit.spec.ts`. diff --git a/docs/plans/entrega-engenharia-p3.md b/docs/plans/entrega-engenharia-p3.md new file mode 100644 index 00000000..bb32a739 --- /dev/null +++ b/docs/plans/entrega-engenharia-p3.md @@ -0,0 +1,212 @@ +# Pass 3 — Auditoria de engenharia e consolidação + +**Data:** 2026-07-28 · **Método:** skill [`engineering-audit`](../../.cursor/skills/engineering-audit/SKILL.md) (Pass-style; cinco varreduras paralelas read-only) · **Tracker:** [IMPROVE-CODE-QUALITY-PLAN.md](../IMPROVE-CODE-QUALITY-PLAN.md) · **Ledger:** [TECH-DEBT.md](../TECH-DEBT.md) + +## Contexto e manchetes + +Terceira passagem de engenharia do repositório (antecedentes: Pass 1 em 2026-07-23/24, Pass 2 em 2026-07-25). Gatilho: pedido de uma varredura de "code smells" + consolidação de componentes/hooks/funções similares — mesmo quando não idênticos — inspirada nas skills de altos padrões de engenharia, com a licença explícita de que **o resultado pode mudar um pouco quando a funcionalidade é a mesma** (protocolo de delta de comportamento abaixo). + +Baseline mecânico no início da auditoria: `tsc` 0 erros, `lint` 0 warnings, `knip` 0 findings (erro de carga do `payload.config.ts` já ledgerado P3), madge 0 ciclos (650 arquivos). + +Manchetes (todas medidas): + +- **0 P0.** Invariantes de segurança íntegros nas cinco varreduras: toda collection declara `access`, todo Local API com `user` usa `overrideAccess: false`, escritas multi-collection transacionais, WebAuthn (B40) com as quatro invariantes verificadas, consent fail-closed por chave estável. +- **1 P1:** o gate e2e segue vermelho — todos os locators ledgerados verificados vivos + nova causa medida: regex de nome de município não ancorada em `campaignMunicipalities.e2e.spec.ts:263` (**23/435 colisões de prefixo** no catálogo). +- **2 P2 de correção:** 4 `safeMessages` que nunca casam com a mensagem lançada (1 alcançável por usuário — advisor decidindo demanda escalada); `consentId` confiado do cliente no fluxo público de assinatura de petição. +- **~1.100+ linhas de duplicação medida** em 9 workstreams de consolidação (scripts CLI ~150, usuários e2e ~280, predicados de access ~120, prólogos de páginas ~110, shells de filtro ~100, config Org↔SD ~58, names-by-ids ×7+, máquinas de list-URL em 4 domínios…). +- **Higiene do ledger:** B34+ F2 **stale** (resolvido pelo B37 — fechar); gatilho MunicipalityHeaderFilter **fechado** (sistema compartilhado shipped); linha e2e-thin stale na contagem (15 specs/31 casos); gatilho de subfoldering de `utilities/` **disparado** (6 domínios: municipality +21, supporter +10, territory +5, leadership +5, webauthn +5, visit +4 novos módulos em 30 dias). +- **11 defer+gatilho** registrados sem ID novo (YAGNI / deep modules — padrão B34+/B37). +- Achados saudáveis que NÃO viraram débito: boundary client/server com zero violações; máquinas otimistas com baseline síncrono em todos os controles (a classe de bug do B34 está limpa); política de pointer só em CSS; live regions corretas; spot-check de ~45 exports do `lib/` sem mortos escondidos atrás da cegueira do knip. + +## Protocolo de delta de comportamento (a licença do dono, segura) + +- **Permitido:** consolidar sobre uma implementação quando as saídas diferem em detalhes pequenos (copy, ordenação, debounce, classes) — cada delta listado por item. +- **Obrigatório:** os pins existentes (unit/int/e2e) são a rede de caracterização. Rodar os suites que pinam os dois lados antes do merge; atualizar pins DELIBERADAMENTE na mesma entrega e listar cada assertion alterada no PR. Pin alterado silenciosamente = defeito. +- **Proibido sem item separado e aprovado:** contratos de URL (congelados, B18), schema de banco (migration = entrega própria), shapes de API pública, comportamento fail-closed de Consent/LGPD. + +## Ondas + +Correção antes de estrutura; estrutura antes de endurecimento de guardas; docs por último. Cada workstream é uma entrega independente com gate completo (`pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` — nunca pipeados — + Aikido nos arquivos editados). Commits de estrutura nunca misturam comportamento; teste vermelho no meio de refactor = revert, não debug. + +### Onda 1 — Correção (pequena e cirúrgica) + +#### P3-A Mensagens de recusa como constantes + guarda de correspondência + +- **Evidência (verificada string a string):** 4 mismatches — `actions/demand.ts:63,98` e `actions/votePledge.ts:143` lançam a forma "…a assessoria **e o candidato**…" enquanto `demandas/[slug]/formActions.ts:26,77` e `municipios/[slug]/pledgeFormActions.ts:63` safelistam a forma sem candidato; `collections/CampaignDemand.ts:113` lança `'Demandas escaladas são decididas pelo Coordenador Geral ou Candidato.'` vs safelisted `'…Coordenador Geral.'` em `demandas/[slug]/formActions.ts:27` — **o caso alcançável por usuário** (advisor decidindo demanda escalada recebe a mensagem genérica). `mapCampaignFormActionError` casa por string exata (`campaignFormActionError.ts:53`), então os 4 colapsam. +1 entrada morta (`podem abrir demandas`, `demandas/nova/formActions.ts:34` — lançada em lugar nenhum). Clusters da mesma classe: `podem gerenciar lideranças.` ×3, `podem gerenciar apoiadores.` ×3, `gerenciam organizações.` ×3, `anexam comprovantes.` ×2, `'Celular brasileiro inválido.'` ×3 (`lib/phone.ts:45`, `lib/schemas/primitives.ts:27`, `collections/Contact.ts:80,87`). +- **Alvo:** constantes por domínio junto às regras (o padrão `MUNICIPALITY_*_MESSAGE` / `STATE_DEPUTY_*_MESSAGE` / `ADVISOR_*` já existe); teste de convenção novo: **todo literal em `safeMessages` aparece como string lançada no grafo de módulos da ação** — teria pego os 4 + o morto. +- **Delta de comportamento:** o motivo real da recusa chega à UI em 4 fluxos; nenhum acesso muda. +- **Pins:** o teste de convenção novo; int existentes de demand/pledge verdes. +- **Esforço:** S. +- **Guarda determinística (3, duas vias):** (a) spec — todo literal de `safeMessages` aparece como string lançada no grafo da ação; (b) spec — `throw new Error(` com literal de string em `campanha/actions/*.ts` e `formActions.ts` só via constante importada (allowlist: invariantes internos em inglês). A reword deixa de ser possível nas duas pontas. + +#### P3-B Consent server-side na assinatura de petição + +- **Evidência:** `submitPetitionSignature.ts:9,40,49` escreve o `consentId` postado pelo cliente em `signature` e `subscription` sem resolução server-side; a página renderiza o texto de `petition.form.consent` (`abaixo-assinado/[id]/page.tsx:153-155`) — um request adulterado registra consentimento a um documento diferente do exibido (proveniência LGPD enfraquecida, sem vazamento). O irmão WhatsApp resolve por chave estável, fail-closed (`submitWhatsapp.ts:16-22`, Pass 2 D3). +- **Alvo:** remover `consentId` do input; a action carrega a petição (leitura pública; `petitionId` já chega) e usa sua relação `form.consent`. +- **Delta:** a assinatura passa a registrar o consent real da petição; um id adulterado torna-se inerte. +- **Pins:** int novo — consentId divergente postado é ignorado. +- **Esforço:** S. +- **Guarda determinística (5 + 6):** o pin int é a guarda comportamental; convenção nova no codebase-map ("referência de consent resolve no servidor por chave/relação, nunca do cliente"). O shape do input não admite guarda mecânica — registrar explicitamente como judgment-only residual. + +#### P3-C E2E gate verde + fixtures alinhados (P1) + +- **Evidência (tudo verificado na árvore):** regex não ancorada em `campaignMunicipalities.e2e.spec.ts:263` (23/435 colisões de prefixo medidas: Conde/Condeúba, Catu/Caturama, Laje ×3, Barra ×4…); `getByLabel('Média')` ×2 com strict-mode violation (trigger do popover + input); checkbox de consent clicado antes da hidratação; cleanup e2e sem os campos E14 (`engagementLevel`/`levelNote`/nome de catálogo) que o cleanup int ganhou em 2026-07-27; o proxy e2e **possui e deleta** a linha compartilhada `apoiador-cadastro` (a isenção `leasedConsentKeys` existe só no int) e reimplementa a máquina de lease que existe três arquivos ao lado; 28 blocos `campaignUser` manuscritos (~280 linhas) onde um builder serve; `campaignListUrl` pinado 3× (spec compartilhado + 5 re-pins de domínio); invite-origin pinado em 2 suites (~160 linhas puras pagando custo int); 43 `fixtures.own()` manuais redundantes com o proxy de auto-ownership (contrato não documentado). +- **Alvo:** locators ancorados (nome exato/slug); espera por sinal de interatividade em vez de existência do elemento; cleanup e2e alinhado ao int (campos E14 + isenção de leased keys) **reutilizando** a máquina de lease; fixture `buildCampaignUser(...)`; colapsar os re-pins de URL na spec compartilhada parametrizada por domínio; invite-origin em uma só camada; documentar o auto-ownership do proxy e remover os `own()` redundantes. +- **Delta:** nenhum em produção; o gate volta a ser confiável — a leitura passa a ser "0 vermelhos", não "comparar a contagem com uma árvore stashed". +- **Pins:** os specs corrigidos; rodada a 1 e 2 workers. +- **Esforço:** M. **Fecha a linha P1 do ledger.** +- **Guarda determinística (3 + 1 + 5):** spec de convenção — `tests/e2e` não interpola nomes de catálogo em `new RegExp` sem âncoras `^…$`; o conjunto de campos resetáveis do município vira módulo único consumido pelos dois cleanups (drift de campos vira vermelho, não divergência silenciosa); spec unitária no proxy e2e pinando a isenção de leased keys. + +### Onda 2 — Consolidação estrutural + +#### P3-D Access layer: políticas nomeadas + +- **Evidência:** fragmento advisor-scope `{ municipality: { in: ids ?? [] } }` ×7 em 6 módulos (`access/votePledges.ts:46`, `demands.ts:42`, `municipalityUpdates.ts:40`, `supporters.ts:48`, `activities.ts:43`, `contacts.ts:44`, `leaderships.ts:41,73,114`); prologue de leitura escopada quase verbatim em `canReadVotePledge` vs `canReadCampaignDemand`; motor memoizado triplicado (`getAccessibleMunicipalityIds` / `getAccessibleLeadershipIds` / `getAccessibleContactIds` + one-liner de fresh-user compartilhado); micro-cluster: branches advisor/leader byte-idênticos em `canCreateSupporter` (`supporters.ts:25-35`); alias cross-domain `allocationDecisions.ts` → predicados de `municipalityUpdates` (a política certa escondida sob o nome de outro domínio); wrapper de consent deprecated (`LeadershipConsentDescriptor`) ainda com 4 call sites; `getAccessibleContactIds` sem o early return `isCampaignUnrestricted → null` dos irmãos (`contacts.ts:29` — seguro hoje só por ordem de chamada). +- **Alvo:** `advisorMunicipalityScopeWhere(field, ids)` + `resolveActorScopedRead(req, …)` em `access/shared.ts`; motor privado `resolveAccessibleIds(req, user, memoKey, compute)`; colapsar os branches; finalizar o rename do wrapper de consent; a linha do early return; e o comentário de intenção LGPD no `removeSupporterData` (fecha a linha P3 "documentar ou alinhar" do ledger). +- **Delta:** nenhum (mesmos `Where` gerados). +- **Pins:** matriz int de access existente + `campaignAccessRequestMemo` (1 leitura de `campaign_user` por request) verdes; um pin por forma de predicado consolidada. +- **Esforço:** M. +- **Guarda determinística (3):** spec banindo `{ municipality: { in:` / `{ municipalities: { in:` fora de `access/shared.ts` (allowlist das formas genuinamente distintas) — collection nova que reescrever o fragmento quebra o build. + +#### P3-E Loaders: montagem de leitura + +- **Evidência:** "resolve nomes por ids" ×7+ (`campaignDashboardData.ts:57-74` — união de slugs que perde tipo para `Record` —, `leadershipData.ts:56-72`, `organizationData.ts:64-79`, `campaignDemandData.ts:67-133,250-259`, `stateDeputyData.ts:251-266`, `votePledgeData.ts:130-146`, `activityDetailPageData.ts:25-51`, `municipalityUpdatePageData.ts:86-99`), com **~8 bypasses sem o comentário justificador** que o invariante exige (incl. `campaignWebAuthnCeremony.ts` ×4, `supporterImportToken.ts` ×4, `contactPhoneInvariant.ts:40`, `campaignConsent.ts:81`, `campaignInvitePageData.ts:61,80`, `municipalityMapData.ts:193`, `municipalityPageData.ts:517`); `MUNICIPALITY_CATALOG_CACHE_TAG` ao lado de `import { revalidatePath } from 'next/cache'` (`municipalityRevalidation.ts:1,11`) e importada por módulo puro — o precedente correto é `electionCache.ts` (tag sozinha, importável de qualquer lugar); fold de agregado de pledges ×2 (`votePledgeData.ts:53-107` vs `votePledgeViews.ts:80-108` — duas superfícies que não podem divergir; o E9 teve de adicionar `lastPledgeAt` nas duas); leitura de escopo ×3 (`campaignMunicipalityScope.ts:39-59`, `municipalityTriggers.ts:202-243`, `visitPlannerData.ts:162-188`) com select-base de 5 campos compartilhado. +- **Alvo:** wrappers tipados de uma linha (`loadCampaignUserNamesByIds`, `loadMunicipalityNamesByIds`…) sobre o núcleo `DynamicFind` já sancionado — o comentário de bypass vive uma vez dentro, e a slug-union que perde tipo morre; tag de cache em módulo neutro + `server-only` na revalidation; `aggregatePledgesWhere` delega o fold por bucket a `aggregateMunicipalityPledgesFromRows`; `loadMunicipalityScope(payload, user, where, { extraSelect })` com os extras na chave do React `cache()`. +- **Delta:** nenhum (mesmos selects; a honestidade de tipo melhora onde a união morria). +- **Pins:** int de listas/detalhes existentes; pin novo — agregado do dossiê === agregado da lista; pin de separação de cache-key por `extraSelect`. +- **Esforço:** M. +- **Guarda determinística (3 + 5):** spec exigindo comentário justificador (`// bypass: …`) em todo `overrideAccess: true` — o invariante social vira erro de build; os pins int (dossiê === lista; cache-key por `extraSelect`) são as guardas comportamentais das duas superfícies. + +#### P3-F List-URL + shells de filtro/busca + +- **Evidência:** `buildXSortHref` ×4 (`municipalityListUrl.ts:451`, `leadershipListUrl.ts:216`, `stateDeputyListUrl.ts:178`, `territoryListUrl.ts:150`), codec de sort ×3, multi-toggle ×4, canonicalize-round-trip ×6, cauda de serialização ×4 — 6–15 linhas cada, mesmo algoritmo, dados de domínio; `firstValue` ×3 (a 3ª grafia privada em `detailTabUi.ts:8-10`; a 4ª inline em `municipalityUpdatePageData.ts:39-42`), `XNotFoundError` ×3, `parseRegionsParam` + `canonicalTerritoryBySearchValue` ×2 (bundle-constrained — não pode entrar no `campaignListUrl`, lição dos ~21 kB); `ActivityFilters.tsx` ↔ `SupporterFilters.tsx` ~50 linhas quase verbatim cada — e **sem a guarda de no-op** do `useCampaignListFilterNavigation` (disparam round-trip RSC até quando o valor já está ativo); `CampaignSearchForm.tsx:30-31,41-43` serializa `?q=` na mão (latente: o primeiro param de filtro que a lista ganhar é silenciosamente dropado no submit); idioma de busca dividido (4 listas com debounce de 1 s, 3 com submit explícito, atividades sem busca — sem decisão registrada). +- **Alvo:** factories parametrizadas por dados em `campaignListUrl.ts` (`createSortToggleHref`, `createSortValueCodec`, `toggleMultiParam`, builder canonicalizante) — o contrato B18 sai preservado **por construção** (round-trip pelo parser do próprio domínio); `useCampaignFilterValues` + `CampaignCollapsibleFilterPanel` adotados pelos dois shells legados; `hrefForQuery` via serializador canônico; idioma de busca unificado no hook (debounce 1 s); módulo `territoryRegionParam.ts` minúsculo para os 2 importadores; `createEntityNotFoundError` para o 3º site da máquina (o gate 3+ bateu). +- **Delta:** a guarda de no-op passa a valer nos 2 shells (menos round-trips); 3 listas passam a buscar com debounce em vez de submit explícito (decisão registrada no PR); resto zero. +- **Pins:** specs de contrato de URL existentes por domínio + spec table-driven novo (padrão `relationMembershipDelta`). +- **Esforço:** M. +- **Guarda determinística (1 + 5):** `hrefForQuery` obrigatório na `CampaignSearchForm` — o consumidor não compila sem o serializador canônico (a serialização manual de `?q=` deixa de ser escrevível); specs de contrato de URL por domínio + spec table-driven das factories pinam a semântica. Adoção das factories em domínios futuros: judgment-only (doc). + +#### P3-G Feedback de forms + primitives + +- **Evidência:** JSX de feedback de form-action ×15 com `aria-live` em só 5 (`ActivityUpdateForm.tsx:39`, `ActivityResultForm.tsx:59`, `LeaderContactForm.tsx:84`, `SupporterForm.tsx:131`, `CampaignInviteForm.tsx:48`) — **10 alertas de erro mudos para AT**; efeito success→toast ×7; classe de input borderless ×4 verbatim (`AdvisorsTable.tsx:223,236,250` + `AdvisorDebouncedTextCell.tsx:121`); prop `showLabels` deprecated sem nenhum call site (`VoteEstimateScenarioStrip.tsx:18,27,32` — knip não vê props). +- **Alvo:** `CampaignFormActionMessage` (erro+sucesso, títulos parametrizados, `aria-live` sempre) + `useCampaignFormSuccessToast(state, onSuccess?)`; constante `campaignInlineInputClassName`; deletar a prop. +- **Delta:** os 10 alertas passam a anunciar (correção de a11y); convergência dos 5 spellings. +- **Pins:** int de forms existentes + uma asserção a11y. +- **Esforço:** S–M. +- **Guarda determinística (3):** spec banindo o JSX cru de feedback de form-action (`state.message && state.status …`) fora do `CampaignFormActionMessage` — mesmo estilo da guarda W4d; o primitive é dono do `aria-live`, então adoção = prevenção. + +### Onda 3 — Estrutura (scripts, collections, camada de rotas) + +#### P3-H Scripts + fold único de nome de município + +- **Evidência:** esqueleto CLI reescrito 5–7× (`die` ×7, `sha256` ×5 byte-idêntico, download→cache→sha256 ×5 ~20 linhas, `writeJson`/`writeText` ×5 — o script mais novo copiou a forma VELHA: drift vivo —, `cacheDir` ×5, preâmbulo dotenv ×6); **dois resolvedores de nome de município divergiram**: o path de apoiadores (`lib/schemas/supporter.ts:19-28`) rejeita 4 das 5 grafias curadas pelo pipeline TSE ("Camacã", "QUINJINGUE", "Santa Terezinha", "Muquém do São Francisco") — um CSV de apoiadores digitado de documentos TSE falha no preview exatamente nos nomes que a campanha já reconciliou; `LOCAL_HOSTS` ×2 divergidos (`assert-local-database` aceita `postgres`, `db-pull` não); `ALLOW_REMOTE_DB` reescrito em `guard-dev-db`; o one-shot `generate-remodel-municipalities-migration.mjs` (146 linhas) sobreviveu ao próprio deploy e segura 2 entradas de allowlist reféns. +- **Alvo:** `scripts/lib/cli.mjs` (a casa existe desde B5 F2): `dieWithLabel`, `sha256Hex`, `loadCliEnv`, `ensureCachedDownload({label,url,ext,cacheDirEnvVar,defaultDir,expectedSha256})`, `writeRepoFile`; fold único (`normalizeSearchPhrase` é o estritamente mais forte) + tabela de aliases como dados + política de erro como dados; `db-pull` importa `LOCAL_HOSTS`; deletar o one-shot + suas 2 entradas de allowlist. +- **Delta:** import de apoiadores passa a aceitar as 4 grafias curadas (**bug latente corrigido**); `identityKey` muda em re-seed (inerte hoje: `runningAgain2026` é 100% `desconhecido` sem caminho de leitura); `db-pull` aceita host `postgres` (é local); saídas dos artefatos byte-idênticas (provadas pelos specs de artefato). +- **Pins:** specs de artefato existentes + casos de alias no int de import; uma rodada de build com cache-hit + `--dry-run`/`--report`. +- **Esforço:** M. +- **Guarda determinística (3 + 5):** spec banindo `function die(`, `createHash('sha256')` e preâmbulo dotenv fora de `scripts/lib/`; spec unitária iterando a tabela de aliases curada e assertando que o resolvedor de apoiadores aceita TODOS — a tabela é dado, divergência futura vira vermelho. + +#### P3-I Collections config + camada de rotas + +- **Evidência:** campo de ator (`createdBy`/`decidedBy`/`declaredBy`) ×5–7 quase verbatim + hook de stamping ×3 verbatim — drift orgânico já aconteceu (Supporter/Leadership ganharam `read:`, CampaignInvite é `required`) — e o precedente de factory existe (`voteEstimateScenarioFields`); `trimmedText` ×4 byte-idêntico em collections (helper puro fora do `lib/`); divergência `[...new Set(relationshipIds())]` vs `uniqueRelationshipIds()`; schema de rota reescrito (`municipios/advisors/route.ts:16-20`) em vez do omit/extend dos irmãos; **guarda W4d furada**: casa só nomes `*FormActions.ts`, então 7 ladders em `actions/*.ts` escapam — 3 expressíveis hoje sem delta (profile avatar ×2, leaderSupporter) e **convite-login fora da allowlist e já expressível** (o mismatch de senha é `FormDataBoundaryError`); `password.ts`/`auth.ts` genuinamente bespoke mas não registrados; `NEXT_REDIRECT` hand-rolled ×2; prólogo de página staff ×22 (~110 linhas) com divergência `return null` ×5 vs `redirect` ×17 (ambos inalcançáveis pela barreira do layout — mas páginas novas copiam o que abrirem primeiro); `requireConsentByKey` chamado cru onde o wrapper nomeado existe (`leaderSupporter.ts:99-104`). +- **Alvo:** `campaignAuditFields.ts` (`systemStampedActorField({name,label,withRead?})` + `deriveCampaignCreatedBy`); `trimmedText` → `lib/`; omit/extend na rota de advisors; migrar os 3–4 ladders expressíveis; guarda ampliada para exports `(state, formData)` em `actions/` **ou** allowlist com razão para password/auth; `requireCampaignPageActor({gate})` para as 22 páginas; wrapper nomeado no leaderSupporter. +- **Delta:** os 5 `return null` ganham redirect no caso inalcançável (invisível); estados das ladders migradas idênticos (incl. chaves de `fieldErrors`). +- **Pins:** int/e2e existentes de avatar, leader supporter, convite login; o spec da guarda ampliada. +- **Esforço:** M. +- **Gatilhos irmãos (registrar, não executar):** `validateMunicipalityAdvisors` ↔ `validateActivityAdvisors` (~20 linhas ×2 — 3º campo de staff-assignment); gêmeo de config Org↔SD (~58 linhas — 3ª entidade de referência staff); esqueleto de transação crua no frontend ×2 (3ª escrita pública). +- **Guarda determinística (3 ×3):** guarda W4d ampliada para exports `(state, formData)` em `actions/` (as 7 ladders invisíveis quebram o build); ban de import de `getCampaignUser()` em `(app)/**/page.tsx` fora do `requireCampaignPageActor`; ban do campo de ator hand-spelled (`canSetCampaignSystemField` + relationship `campaignUser` readOnly) fora da factory. + +#### P3-J Subfoldering de `utilities/` — gatilho D1 disparado + +- **Evidência:** 122 módulos top-level (eram 92 no Pass 2); novos em 30 dias (`git --diff-filter=A`): municipality +21, supporter +10, territory +5, leadership +5, webauthn +5, visit +4 — **6 domínios passaram do gatilho "3º módulo novo de um domínio num mês"** registrado na decisão D1 do Pass 2; `campaignTime.ts` (140 linhas, zero imports) e `relationship.ts` (33 linhas, zero imports) são puros e estão na camada errada. +- **Alvo:** executar o subfoldering D1 para os domínios que dispararam (`municipality/`, `supporter/`, `territory/`, `leadership/`, `visit/`, `webauthn/`) — imports diretos, **sem barrels** (a lição do barrel `campaignAccess` + sidebar já foi paga); mover `campaignTime`/`relationship` → `lib/`; atualizar `codebase-map.mdc` + `ARCHITECTURE.md` no mesmo PR (a convenção exige os dois juntos). +- **Delta:** nenhum (churn de paths apenas). +- **Pins:** `check:cycles` + guarda de convenções; sem pins de comportamento. +- **Esforço:** M (mecânico; último da onda para não invalidar paths das consolidações anteriores). +- **Guarda determinística (3):** allowlist pinada dos módulos top-level de `src/utilities/` — módulo novo top-level falha o build até ser registrado (codifica o gatilho D1 em vez de depender de memória). + +### Onda 4 — Guardas, constantes e docs + +#### P3-K Guardas + single-sourcing + +- **Guardas:** regex de vocabulário tolerante a acento (`/pra[çc]|n[úu]cleo/i` + allowlist dos 2 hits legítimos — hoje `praca`/`nucleo` sem acento passam, e a linha irmã "Plano de Ação" já foi endurecida por isso); guarda `server-only` ampliada para value-imports de `next/cache` / `next/server` / `next/headers` (`municipalityRevalidation.ts` e `campaignJsonMutationRoute.ts` são server-bound e invisíveis hoje); ESLint `no-restricted-imports` barrando value-imports vindos de `components/` dos 3 módulos client-safe que carregam o artefato TSE de 623 KB (`municipalityTerritorialClass`, `municipalityPotential`, `territoryIntraCaptureBenchmark` — o B13 evitou por disciplina, não por mecanismo). +- **Single-sourcing (cada um S, um PR por cluster afim):** tipo territorial-class → `lib/territorialClassAnchors.ts` (adicionar uma classe vira erro de tipo no lib — hoje compila verde e mis-sorta); `DAY_MS` ×3 → casa neutra (e sai da interface pública do `suggestionCatalog`); `MAX_VOTE_COUNT` ×6 (duas camadas validando o mesmo campo com o mesmo bound escrito duas vezes); exportar `passwordSchema`/`tokenSchema` de `campaignPassword.ts` (o `invite.ts` copiou); `lib/electionYears.ts` leaf (o espelho do `suggestionCatalog` morre junto com a desculpa documentada); `formatBrazilianPhoneDisplay = formatBrazilianPhoneInput` (delega, mantém nome/doc); transform de dedup no `advisorMunicipalitiesBatchSchema`; `formatElectionNumber` nos 3 formatters de `voteEstimate.ts` (o E18 pinou zero frações; eles reescritos sem o pin); deletar `hasAnyEstimate` (gêmeo mais fraco de `hasAnyVoteEstimate`). +- **Esforço:** S cada. +- **Guarda determinística: esta É a workstream de guardas** — 2 ESLint + 3 specs de convenção endurecidas/novas + 1 single-sourcing de tipo (ver Mapa de guardas). + +#### P3-L Ledger + docs reconcile + +- Fechar B34+ F2 (resolvido por B37) e o gatilho MunicipalityHeaderFilter; atualizar e2e-thin (15 specs/31 casos) e a linha Praça-copy (W4f executado; resto = textos de consent provisórios Onda 0, lote jurídico — D6); `codebase-map.mdc`: exceção `TerritoryOverviewTable` morta (a rota usa `CampaignTable` com sort de URL desde B21) + nota de que `lib/formData.ts` é o único `server-only` do lib; `TESTING.md` truth-up (backlog de caracterização: `posts.ts` e `activityViewModels.ts` seguem sem pin — confirmado nesta auditoria). +- **Esforço:** S. +- **Guarda determinística (6):** doc — a convenção "codebase-map + ARCHITECTURE no mesmo PR" já existe; reafirmar. Sem mecanismo. + +## Mapa de guardas (prevenção de recorrência) + +Regra desta passada (decisão D6 do tracker): nenhum item fecha sem a classe de prevenção registrada, e guarda nova shipa na MESMA entrega do fix — nunca "depois". Escala de determinismo: **(1) tipo** (o estado ruim não compila) → **(2) ESLint** (`no-restricted-*`) → **(3) spec de convenção** (`codebaseConventions.unit.spec.ts`, table-driven) → **(4) análise estática CI** (knip/madge) → **(5) pin comportamental** → **(6) doc/convenção** (último recurso, declarado como tal). + +**Guardas novas que esta passada shipa (12):** + +| Guarda | Classe | WS | +| ---------------------------------------------------------------------------------------------- | ------ | ---- | +| safeMessages ↔ throw, duas vias (literal safelisted existe como throw; throw só via constante) | 3 | P3-A | +| Comentário justificador obrigatório em `overrideAccess: true` | 3 | P3-E | +| Âncoras `^…$` em regex de e2e com nomes de catálogo | 3 | P3-C | +| Fragmento advisor-scope banido fora de `access/shared.ts` | 3 | P3-D | +| JSX cru de feedback de form-action banido fora do primitive | 3 | P3-G | +| Esqueleto CLI (`die`/sha256/dotenv) banido fora de `scripts/lib/` | 3 | P3-H | +| Allowlist pinada dos top-level de `src/utilities/` | 3 | P3-J | +| `getCampaignUser()` banido em `(app)/**/page.tsx` fora do helper | 3 | P3-I | +| Campo de ator hand-spelled banido fora da factory | 3 | P3-I | +| `server-only` ampliada a `next/cache` / `next/server` / `next/headers` | 3 | P3-K | +| Vocabulário banido tolerante a acento | 3 | P3-K | +| ESLint `no-restricted-imports` do artefato TSE (value-import vindo de `components/`) | 2 | P3-K | + +**Guardas existentes endurecidas (3):** W4d filename guard → exports `(state, formData)` em `actions/` (P3-I); sweep `server-only` (P3-K); regex de vocabulário (P3-K). + +**Fonte única / tipo (classe 1):** conjunto de campos resetáveis compartilhado int↔e2e (P3-C); `hrefForQuery` obrigatório (P3-F); união territorial-class em `lib/territorialClassAnchors.ts` (P3-K). + +**Pins comportamentais (classe 5):** aliases curados resolvem em todo path (P3-H); dossiê === lista (P3-E); cache-key por `extraSelect` (P3-E); consentId adulterado ignorado (P3-B); isenção de leased keys no proxy e2e (P3-C). + +**Judgment-only residual (declarado):** adoção das factories de list-URL em domínios futuros; chamadas do gate de abstração (3+ call sites); trade-offs de bundle; gramática de busca unificada — prevenção = docs + listas rejected-with-reason + esta trilha de decisões. Não fingir que doc é guarda. + +## Registrados como defer + gatilho (sem ID novo) + +| Par | Gatilho | +| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Token assinado HMAC ×2 (`supporterImportToken` ↔ `campaignWebAuthnChallenge`, ~30 linhas + cookie options ×2) | 3º token assinado → `signedToken.ts` | +| Prólogo de redemption ×2 (`campaignInviteRedemption.ts:91-168` ↔ `170-297`) | 3º tipo de convite → policy wrapper | +| Montagem de avaliador ×2 (`municipalityTriggers` ↔ `visitPlannerData`) | 3º avaliador (o wiring "não vá" do E13 pode ser) → `loadMunicipalityEvaluationInputs` | +| Esqueleto do `AdvisorDebouncedTextCell` (3 deltas medidos e justificados vs `useCampaignCellAutosave`) | 2ª célula free-text debounced → transport seam + `revertOnFailure` | +| Editor de set estilo roster (`MunicipalityListAdvisorsControl`, reconciliador por seq — provado correto pelo advisory lock do servidor) | 3º editor roster → extrair a máquina ou modo "roster" do `RelationChipCell` | +| `validateXAdvisors` ×2 | 3º campo de staff-assignment → `assertEligibleCampaignStaffIds` | +| Gêmeo de config Org↔SD (~58 linhas) | 3ª entidade de referência staff → factories de name/slug | +| Esqueleto de transação crua no frontend ×2 | 3ª escrita pública multi-collection | +| Gêmeos `advisorData` (`:90-116` ↔ `:118-147`) | 3ª leitura por assessor | +| `memoizeProcess` ×4 (`let x = null; if (x) return x; …`) | dobrar em qualquer entrega que toque esses módulos | +| Fórmula LQ ×2 (classifier 2022-only vs per-year nos triggers) | 3º escritor de LQ → `locationQuotient` no `lib/` | +| Sentinela + `exists:false` OR-branch ×2 | já registrado in-code ("a third should extract") — mantido | + +**B34+ F10** (batch twins município-side, 2 sites) verificado: raciocínio e gatilho continuam corretos onde estão. + +## Explicitamente fora (e por quê) + +- `municipalityListUrl.ts` (537 linhas/23 exports) e `municipalityListFilters.ts` (417/24): cruzam o limiar de god module mas são contratos coesos e congelados (B18) — split por estética churna a superfície congelada. Watch, não split. +- `import-projecao.mjs` (1.214 linhas): pipeline one-shot faseado que ganha o tamanho; extração quando virar rotina mensal. +- `searchMunicipalityPortfolio` (74 linhas): linear com early-returns; borderline, fica. +- `resolveOgImage` ×2 por request de artigo (re-parse do Lexical): micro; revisitar só se profiling mandar. +- `cities.ts` (5.651 linhas) e os 4 catálogos nome-chaveados: dados estáticos com fixtures de evidência — julgados como dados, não como código. +- Qualquer mudança de URL, schema de banco, API pública ou texto de Consent: fora do escopo desta passada (protocolo acima). + +## Look-alikes rejeitados (não reabrir sem evidência nova) + +`formatLqMultiple` 1 vs 2 casas (política de precisão proposital — a borda 0,95× imprimiria "1,0×" em duas faixas adjacentes); 4 catálogos nome-chaveados (proveniências independentes, cada uma com sua fixture de evidência); 3 lazy loaders de `bahiaGeometries` (~15 linhas poupadas por uma interface a mais — não paga); `campaignRelationOptions` ×5 (rejeitado por medição: o prologue é uniforme e o conteúdo é o label); `formatXActiveFiltersSummary` / sort-options / clear ×3–4 (2–3 linhas de esqueleto, o conteúdo é copy de domínio); `RelationMultiSelect` ↔ `RelationChipCell` (state ownership diferente: draft FormData vs writes otimistas); 3 updates simples de `municipality.ts` (honestidade de tipo > ~15 linhas); curries `getFreshStaffActor` ×5 (a mensagem já é o parâmetro); `buildMunicipalityFilterOptionHref` re-derivando regras do parser (fast path medido, documentado); `supporterListFilters` vs `supporterListSqlFilters` (espelho SQL deliberado do C6, núcleo já extraído). + +## Regras de execução (valem para cada workstream) + +1. Uma consolidação por entrega; gate completo por entrega, comandos nunca pipeados; Aikido nos arquivos editados. +2. Commits de estrutura nunca misturam comportamento; teste vermelho no meio de refactor = revert, não debug. +3. Migrations congeladas nunca editadas; mudança de schema = `pnpm migrate:create` (nenhuma prevista nesta passada). +4. A lista rejected-with-reason da skill `engineering-audit` vincula a implementação. +5. Sobras e débitos novos → ledger via `capture-review-debts`; nada vive só no chat. +6. Itens que inflarem além do appetite viram roadmap via `roadmap-item`. +7. Nenhum item fecha sem a classe de prevenção (1–6) registrada no PR; guarda nova shipa junto com o fix, nunca "depois". diff --git a/eslint.config.mjs b/eslint.config.mjs index d392d5da..9953f30e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -143,6 +143,34 @@ const eslintConfig = [ ], }, }, + { + // The 623 KB committed TSE artifact must never enter a client bundle. + // These three loaders carry it transitively; server components and other + // utilities import them freely, client components must not (P3-K — B13 + // avoided it by discipline, not mechanism). Types stay safe. + files: ['src/components/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@/utilities/municipality/municipalityTerritorialClass', + '@/utilities/municipality/municipalityPotential', + '@/utilities/territory/territoryIntraCaptureBenchmark', + '@/lib/bahiaElectionAggregates', + '@/lib/electionAggregates/**', + ], + message: + 'The committed TSE artifact (~623 KB) must not reach the browser bundle — resolve the values in the RSC and pass them down as props.', + allowTypeImports: true, + }, + ], + }, + ], + }, + }, { // Underscore-prefixed bindings are deliberate placeholders (unused action // state, ignored tuple slots); everything else unused is dead code. diff --git a/playwright.config.ts b/playwright.config.ts index 3d41b74e..a9efe28d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -32,6 +32,13 @@ export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, workers, + /* + * The 30 s default does not cover a whole journey (login + several dev-mode + * RSC navigations) when the dev server compiles cold under load — measured + * during P3-C: login's waitForURL blew the budget with the machine at load + * ~7. 60 s covers cold-compile journeys without masking real hangs. + */ + timeout: 60_000, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ @@ -46,6 +53,16 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, + expect: { + /* + * The default 5 s does not cover a dev-mode RSC round-trip on the heavy + * campaign list routes once the machine is loaded (B17 measured this: the + * failing set drifted with load average, and the picker's own specs pass + * only with their own budgets). 10 s covers the cold dev compile without + * masking real regressions for a whole suite run. + */ + timeout: 10_000, + }, projects: [ { name: 'setup', diff --git a/scripts/assert-local-database.mjs b/scripts/assert-local-database.mjs index 2503af49..acdb658b 100644 --- a/scripts/assert-local-database.mjs +++ b/scripts/assert-local-database.mjs @@ -3,23 +3,24 @@ * Host allowlist + ALLOW_REMOTE_DB escape hatch. */ -const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB' -const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres']) +import { + dieWithLabel, + isRemoteDbOverrideSet, + LOCAL_HOSTS, + ALLOW_REMOTE_DB_FLAG as OVERRIDE_FLAG, +} from './lib/cli.mjs' /** * @param {string} label Log/error prefix, e.g. "seed:tse" * @param {string} [usageHint] Extra lines shown when refusing a remote host */ export const assertLocalDatabase = (label, usageHint = '') => { - const die = (message) => { - console.error(`\n[${label}] ${message}\n`) - process.exit(1) - } + const die = dieWithLabel(label) const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) die('DATABASE_URL is not set. Refusing to continue.') - if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') { + if (isRemoteDbOverrideSet()) { console.warn( `\n[${label}] ${OVERRIDE_FLAG} is set — connecting to a remote database on purpose. Be careful.\n`, ) diff --git a/scripts/build-bahia-demographics.mjs b/scripts/build-bahia-demographics.mjs index bae6a71a..e556b553 100644 --- a/scripts/build-bahia-demographics.mjs +++ b/scripts/build-bahia-demographics.mjs @@ -15,11 +15,11 @@ * DEMOGRAPHICS_CACHE_DIR=./data/demographics pnpm build:demographics */ -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { dieWithLabel, ensureCachedDownload, writeRepoFile } from './lib/cli.mjs' + const { bahiaMunicipalityCodes } = await import('../src/lib/bahiaMunicipalityCodes.ts') const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts') @@ -50,41 +50,22 @@ const AGE_CATEGORY_TO_BAND = Object.fromEntries( ), ) -const die = (message) => { - console.error(`\n[build:demographics] ${message}\n`) - process.exit(1) -} - -const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') +const die = dieWithLabel('build:demographics') const cacheDir = () => process.env.DEMOGRAPHICS_CACHE_DIR || join(ROOT, 'data', 'demographics') -const ensureCachedJson = async ({ key, url }) => { - const dir = cacheDir() - await mkdir(dir, { recursive: true }) - const path = join(dir, `${key}.json`) - try { - await access(path) - console.log(`[build:demographics] cache hit ${path}`) - const buffer = await readFile(path) - return { url, hash: sha256(buffer), json: JSON.parse(buffer.toString('utf8')) } - } catch (error) { - if (error?.code !== 'ENOENT') throw error - } - console.log(`[build:demographics] downloading ${url}`) - const buffer = await downloadToBuffer(url) - const hash = sha256(buffer) - await writeFile(path, buffer) - console.log(`[build:demographics] saved ${path} (${buffer.length} bytes, sha256=${hash})`) - return { url, hash, json: JSON.parse(buffer.toString('utf8')) } -} +const ensureCachedJson = ({ key, url }) => + ensureCachedDownload({ + label: 'build:demographics', + key, + url, + ext: 'json', + cacheDir: cacheDir(), + download: downloadToBuffer, + }) -const writeText = async (relativePath, body) => { - const path = join(ROOT, relativePath) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, body) - console.log(`[build:demographics] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) -} +const writeText = (relativePath, body) => + writeRepoFile({ label: 'build:demographics', root: ROOT, relativePath, body }) const parseSidraValue = (value) => { if (value === '...' || value === '-' || value === 'X' || value == null || value === '') return 0 diff --git a/scripts/build-bahia-geometries.mjs b/scripts/build-bahia-geometries.mjs index b2c570c7..c512e48a 100644 --- a/scripts/build-bahia-geometries.mjs +++ b/scripts/build-bahia-geometries.mjs @@ -21,10 +21,9 @@ * GEOMETRIES_CACHE_DIR=./data/geometries pnpm build:geometries */ -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { dieWithLabel, ensureCachedDownload, sha256Hex, writeRepoFile } from './lib/cli.mjs' import { merge } from 'topojson-client' import { topology } from 'topojson-server' @@ -56,42 +55,31 @@ const SOURCES = { }, } -const die = (message) => { - console.error(`\n[build:geometries] ${message}\n`) - process.exit(1) -} - -const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') +const die = dieWithLabel('build:geometries') const cacheDir = () => process.env.GEOMETRIES_CACHE_DIR || join(ROOT, 'data', 'geometries') -const ensureCachedJson = async ({ key, url, ext }) => { - const dir = cacheDir() - await mkdir(dir, { recursive: true }) - const path = join(dir, `${key}.${ext}`) - try { - await access(path) - console.log(`[build:geometries] cache hit ${path}`) - const buffer = await readFile(path) - return { url, hash: sha256(buffer), json: JSON.parse(buffer.toString('utf8')) } - } catch (error) { - if (error?.code !== 'ENOENT') throw error - } - console.log(`[build:geometries] downloading ${url}`) - const buffer = await downloadToBuffer(url) - const hash = sha256(buffer) - await writeFile(path, buffer) - console.log(`[build:geometries] saved ${path} (${buffer.length} bytes, sha256=${hash})`) - return { url, hash, json: JSON.parse(buffer.toString('utf8')) } -} +const ensureCachedJson = ({ key, url, ext }) => + ensureCachedDownload({ + label: 'build:geometries', + key, + url, + ext, + cacheDir: cacheDir(), + download: downloadToBuffer, + }) -const writeJson = async (relativePath, value) => { - const path = join(ROOT, relativePath) - await mkdir(dirname(path), { recursive: true }) - const body = typeof value === 'string' ? value : `${JSON.stringify(value)}\n` - await writeFile(path, body) - console.log(`[build:geometries] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) -} +const writeJson = (relativePath, value) => + writeRepoFile({ + label: 'build:geometries', + root: ROOT, + relativePath, + body: + typeof value === 'string' + ? value + : `${JSON.stringify(value)} +`, + }) const buildMunicipalityCodesModule = (entries, provenance) => { const lines = entries @@ -262,7 +250,7 @@ const main = async () => { const assignmentRows = codeEntries .map(([municipality, code]) => `M\t${municipality}\t${code}\n`) .join('') - const evidenceSha256 = createHash('sha256').update(assignmentRows).digest('hex') + const evidenceSha256 = sha256Hex(assignmentRows) const fixture = { provenance: { diff --git a/scripts/build-bahia-tse-city-codes.mjs b/scripts/build-bahia-tse-city-codes.mjs index f5113f39..26cb906f 100644 --- a/scripts/build-bahia-tse-city-codes.mjs +++ b/scripts/build-bahia-tse-city-codes.mjs @@ -15,10 +15,9 @@ * TSE_CACHE_DIR=./data/tse pnpm build:tse-city-codes */ -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { dieWithLabel, ensureCachedDownload, sha256Hex, writeRepoFile } from './lib/cli.mjs' const { canonicalizeMunicipalityName, UnknownMunicipalityError } = await import('../src/lib/electionResults.ts') @@ -34,49 +33,31 @@ const SOURCE = { csvName: 'detalhe_votacao_munzona_2022_BA.csv', } -const die = (message) => { - console.error(`\n[build:tse-city-codes] ${message}\n`) - process.exit(1) -} - -const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') +const die = dieWithLabel('build:tse-city-codes') const cacheDir = () => process.env.TSE_CACHE_DIR || join(ROOT, 'data', 'tse') -const ensureCachedZip = async () => { - const dir = cacheDir() - await mkdir(dir, { recursive: true }) - const path = join(dir, `${SOURCE.key}.zip`) - try { - await access(path) - console.log(`[build:tse-city-codes] cache hit ${path}`) - const buffer = await readFile(path) - return { url: SOURCE.url, hash: sha256(buffer), buffer } - } catch (error) { - if (error?.code !== 'ENOENT') throw error - } - console.log(`[build:tse-city-codes] downloading ${SOURCE.url}`) - const buffer = await downloadToBuffer(SOURCE.url) - const hash = sha256(buffer) - await writeFile(path, buffer) - console.log(`[build:tse-city-codes] saved ${path} (${buffer.length} bytes, sha256=${hash})`) - return { url: SOURCE.url, hash, buffer } -} +const ensureCachedZip = () => + ensureCachedDownload({ + label: 'build:tse-city-codes', + key: SOURCE.key, + url: SOURCE.url, + ext: 'zip', + cacheDir: cacheDir(), + download: downloadToBuffer, + }) -const writeJson = async (relativePath, value) => { - const path = join(ROOT, relativePath) - await mkdir(dirname(path), { recursive: true }) - const body = `${JSON.stringify(value, null, 2)}\n` - await writeFile(path, body) - console.log(`[build:tse-city-codes] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) -} +const writeJson = (relativePath, value) => + writeRepoFile({ + label: 'build:tse-city-codes', + root: ROOT, + relativePath, + body: `${JSON.stringify(value, null, 2)} +`, + }) -const writeText = async (relativePath, body) => { - const path = join(ROOT, relativePath) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, body) - console.log(`[build:tse-city-codes] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) -} +const writeText = (relativePath, body) => + writeRepoFile({ label: 'build:tse-city-codes', root: ROOT, relativePath, body }) const buildCityCodesModule = (entries, provenance) => { const lines = entries @@ -176,7 +157,7 @@ const main = async () => { const assignmentRows = codeEntries .map(([municipality, code]) => `M\t${municipality}\t${code}\n`) .join('') - const evidenceSha256 = createHash('sha256').update(assignmentRows).digest('hex') + const evidenceSha256 = sha256Hex(assignmentRows) await writeJson('tests/fixtures/bahia-tse-city-codes.official.json', { provenance: { diff --git a/scripts/build-election-aggregates.mjs b/scripts/build-election-aggregates.mjs index cdb5425b..bb1b0288 100644 --- a/scripts/build-election-aggregates.mjs +++ b/scripts/build-election-aggregates.mjs @@ -31,23 +31,22 @@ * writes only src/lib/electionAggregates/. */ -import { config as loadEnv } from 'dotenv' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { getPayload } from 'payload' +import { loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' -loadEnv({ path: '.env.local' }) -loadEnv({ path: '.env' }) +loadCliEnv() const payloadConfig = (await import('../src/payload.config.ts')).default const { BASELINE_TICKET_2022, ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } = await import('../src/lib/electionResults.ts') const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts') const { municipalityElectionGeography } = - await import('../src/utilities/municipalityElectionGeography.ts') + await import('../src/utilities/municipality/municipalityElectionGeography.ts') const { loadCampoFederalVotesByCityZone, loadCandidateVotesByCityZone, @@ -57,7 +56,7 @@ const { sumCandidateVotesForGeography, sumOfficeTallyForGeography, sumVotesForGeography, -} = await import('../src/utilities/municipalityElectoralBaseline.ts') +} = await import('../src/utilities/municipality/municipalityElectoralBaseline.ts') const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const OUTPUT_DIR = join(ROOT, 'src', 'lib', 'electionAggregates') diff --git a/scripts/build-municipality-zone-geometries.mjs b/scripts/build-municipality-zone-geometries.mjs index d55e90df..78063640 100644 --- a/scripts/build-municipality-zone-geometries.mjs +++ b/scripts/build-municipality-zone-geometries.mjs @@ -24,10 +24,9 @@ * GEOMETRIES_CACHE_DIR=./data/geometries pnpm build:municipality-zone-geometries */ -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { dieWithLabel, ensureCachedDownload, writeRepoFile } from './lib/cli.mjs' import shp from 'shpjs' import { merge } from 'topojson-client' @@ -98,44 +97,32 @@ const SPLIT_NEIGHBORHOOD_ZONES = { const REPORT_ONLY = process.argv.includes('--report') -const die = (message) => { - console.error(`\n[build:municipality-zone-geometries] ${message}\n`) - process.exit(1) -} +const die = dieWithLabel('build:municipality-zone-geometries') const log = (message) => console.log(`[build:municipality-zone-geometries] ${message}`) -const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') - const cacheDir = () => process.env.GEOMETRIES_CACHE_DIR || join(ROOT, 'data', 'geometries') const ensureCachedBinary = async ({ key, url, ext }) => { - const dir = cacheDir() - await mkdir(dir, { recursive: true }) - const path = join(dir, `${key}.${ext}`) - try { - await access(path) - log(`cache hit ${path}`) - const buffer = await readFile(path) - return { hash: sha256(buffer), buffer } - } catch (error) { - if (error?.code !== 'ENOENT') throw error - } - log(`downloading ${url}`) - const buffer = await downloadToBuffer(url) - const hash = sha256(buffer) - await writeFile(path, buffer) - log(`saved ${path} (${buffer.length} bytes, sha256=${hash})`) + const { hash, buffer } = await ensureCachedDownload({ + label: 'build:municipality-zone-geometries', + key, + url, + ext, + cacheDir: cacheDir(), + download: downloadToBuffer, + }) return { hash, buffer } } -const writeJson = async (relativePath, value) => { - const path = join(ROOT, relativePath) - await mkdir(dirname(path), { recursive: true }) - const body = `${JSON.stringify(value)}\n` - await writeFile(path, body) - log(`wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) -} +const writeJson = (relativePath, value) => + writeRepoFile({ + label: 'build:municipality-zone-geometries', + root: ROOT, + relativePath, + body: `${JSON.stringify(value)} +`, + }).then(() => log(`wrote ${relativePath}`)) /** Accent-folded, punctuation-free key; parentheses (the TRE's split notes) drop. */ const normalizeNeighborhood = (name) => diff --git a/scripts/db-pull.mjs b/scripts/db-pull.mjs index 6158ed63..73231951 100644 --- a/scripts/db-pull.mjs +++ b/scripts/db-pull.mjs @@ -18,18 +18,15 @@ * DATABASE_URL_UNPOOLED value in .env.local). */ import { execFileSync } from 'node:child_process' +import { dieWithLabel, LOCAL_HOSTS } from './lib/cli.mjs' const PG_IMAGE = 'postgres:17-alpine' // must match the prod major version const PII_TABLE_REGEX = '^(contact|signature|subscription)(_|$)' -const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']) const prodUrl = process.env.PROD_DATABASE_URL const localUrl = process.env.LOCAL_DATABASE_URL ?? 'postgresql://teqo:teqo@localhost:5432/teqo' -const die = (message) => { - console.error(`\n[db:pull] ${message}\n`) - process.exit(1) -} +const die = dieWithLabel('db:pull') if (!prodUrl) { die( diff --git a/scripts/generate-remodel-municipalities-migration.mjs b/scripts/generate-remodel-municipalities-migration.mjs deleted file mode 100644 index 368371c4..00000000 --- a/scripts/generate-remodel-municipalities-migration.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import { writeFileSync } from 'fs' -import { municipalityCatalog } from '../src/lib/municipalityCatalog.ts' - -const esc = (s) => String(s).replace(/'/g, "''") -const seedRows = municipalityCatalog - .map( - (e) => - `(${[ - `'${esc(e.name)}'`, - `'${esc(e.slug)}'`, - `'${e.kind}'::"public"."enum_municipality_kind"`, - `'${esc(e.city)}'`, - `'${esc(e.region)}'`, - `'${e.ibgeCode}'`, - `'${e.tseCityCode}'`, - e.zoneNumber ?? 'NULL', - ].join(', ')})`, - ) - .join(',\n ') - -const content = `import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres' - -export async function up({ db }: MigrateUpArgs): Promise { - // Destructive remodel: Praça → Município (product decision 2026-07-23). - await db.execute(sql\` - DELETE FROM "campaign_invite"; - DELETE FROM "supporter"; - DELETE FROM "supporter_import_batch"; - DELETE FROM "action_plan"; - DELETE FROM "leadership"; - DELETE FROM "vote_pledge"; - DELETE FROM "campaign_demand"; - DELETE FROM "plaza_update"; - DELETE FROM "organization"; - \`) - - await db.execute(sql\` - DROP TABLE IF EXISTS "plaza_update" CASCADE; - DROP TABLE IF EXISTS "plaza_rels" CASCADE; - DROP TABLE IF EXISTS "plaza_strengths" CASCADE; - DROP TABLE IF EXISTS "plaza_risks" CASCADE; - DROP TABLE IF EXISTS "plaza" CASCADE; - DROP TYPE IF EXISTS "public"."enum_plaza_kind"; - DROP TYPE IF EXISTS "public"."enum_plaza_priority"; - DROP TYPE IF EXISTS "public"."enum_plaza_political_trend_status"; - DROP TYPE IF EXISTS "public"."enum_plaza_update_kind"; - \`) - - await db.execute(sql\` - CREATE TYPE "public"."enum_municipality_kind" AS ENUM('municipio', 'zona'); - CREATE TYPE "public"."enum_municipality_priority" AS ENUM('alta', 'normal'); - CREATE TYPE "public"."enum_municipality_political_trend_status" AS ENUM('favoravel', 'neutra', 'desfavoravel'); - CREATE TYPE "public"."enum_municipality_update_kind" AS ENUM('semanal', 'urgente', 'nota'); - - CREATE TABLE "municipality_strengths" ( - "_order" integer NOT NULL, - "_parent_id" integer NOT NULL, - "id" varchar PRIMARY KEY NOT NULL, - "text" varchar NOT NULL - ); - - CREATE TABLE "municipality_risks" ( - "_order" integer NOT NULL, - "_parent_id" integer NOT NULL, - "id" varchar PRIMARY KEY NOT NULL, - "text" varchar NOT NULL - ); - - CREATE TABLE "municipality" ( - "id" serial PRIMARY KEY NOT NULL, - "name" varchar NOT NULL, - "slug" varchar NOT NULL, - "kind" "enum_municipality_kind" NOT NULL, - "city" varchar NOT NULL, - "region" varchar NOT NULL, - "ibge_code" varchar NOT NULL, - "tse_city_code" varchar NOT NULL, - "zone_number" numeric, - "priority" "enum_municipality_priority" DEFAULT 'normal', - "vote_goals_good" numeric, - "vote_goals_regular" numeric, - "vote_goals_minimum" numeric, - "expected_votes_pessimistic" numeric, - "expected_votes_central" numeric, - "expected_votes_optimistic" numeric, - "political_trend_status" "enum_municipality_political_trend_status", - "political_trend_note" varchar, - "political_trend_recorded_by_id" integer, - "political_trend_recorded_at" timestamp(3) with time zone, - "dobradinha_notes" varchar, - "next_steps" varchar, - "last_update_at" timestamp(3) with time zone, - "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, - "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL - ); - - CREATE TABLE "municipality_rels" ( - "id" serial PRIMARY KEY NOT NULL, - "order" integer, - "parent_id" integer NOT NULL, - "path" varchar NOT NULL, - "campaign_user_id" integer, - "state_deputy_id" integer - ); - - CREATE TABLE "municipality_update" ( - "id" serial PRIMARY KEY NOT NULL, - "municipality_id" integer NOT NULL, - "author_id" integer, - "kind" "enum_municipality_update_kind" NOT NULL, - "body" varchar NOT NULL, - "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, - "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL - ); - - CREATE UNIQUE INDEX "municipality_slug_idx" ON "municipality" USING btree ("slug"); - CREATE UNIQUE INDEX "municipality_name_idx" ON "municipality" USING btree ("name"); - \`) - - await db.execute(sql\`ALTER TYPE "public"."enum_campaign_user_role" ADD VALUE IF NOT EXISTS 'candidate'\`) - await db.execute(sql\`ALTER TYPE "public"."enum_supporter_source" ADD VALUE IF NOT EXISTS 'lideranca'\`) - - await db.execute(sql\` - ALTER TABLE "supporter" RENAME COLUMN "plaza_id" TO "municipality_id"; - ALTER TABLE "vote_pledge" RENAME COLUMN "plaza_id" TO "municipality_id"; - ALTER TABLE "action_plan" RENAME COLUMN "plaza_id" TO "municipality_id"; - ALTER TABLE "campaign_demand" RENAME COLUMN "plaza_id" TO "municipality_id"; - ALTER TABLE "leadership_rels" RENAME COLUMN "plaza_id" TO "municipality_id"; - ALTER TABLE "organization_rels" RENAME COLUMN "plaza_id" TO "municipality_id"; - \`) - - await db.execute(sql\` - INSERT INTO "municipality" ("name", "slug", "kind", "city", "region", "ibge_code", "tse_city_code", "zone_number") - VALUES - ${seedRows} - ON CONFLICT DO NOTHING; - \`) -} - -export async function down({ db }: MigrateDownArgs): Promise { - throw new Error('remodel_municipalities is irreversible') -} -` - -writeFileSync('src/migrations/20260723_200000_remodel_municipalities.ts', content) -console.log(`Wrote migration with ${municipalityCatalog.length} seed rows`) diff --git a/scripts/guard-dev-db.mjs b/scripts/guard-dev-db.mjs index a4db0f4e..e8e5fc86 100644 --- a/scripts/guard-dev-db.mjs +++ b/scripts/guard-dev-db.mjs @@ -7,16 +7,15 @@ * * Escape hatch (use with care): ALLOW_REMOTE_DB=true pnpm dev */ -import { config as loadEnv } from 'dotenv' import { assertLocalDatabase } from './assert-local-database.mjs' import { diagnoseDatabaseTarget, warnOnSharedDataVolumes } from './db-doctor.mjs' +import { isRemoteDbOverrideSet, loadCliEnv } from './lib/cli.mjs' // Mirror Next.js precedence (.env.local wins over .env) WITHOUT overriding a // DATABASE_URL that is already set in the real environment (e.g. injected by // Playwright's webServer). dotenv's default `override: false` guarantees this. -loadEnv({ path: '.env.local' }) -loadEnv({ path: '.env' }) +loadCliEnv() assertLocalDatabase( 'guard-dev-db', @@ -30,7 +29,7 @@ assertLocalDatabase( // start, who is holding the port) instead of letting Next/Payload throw a // bare ECONNREFUSED minutes into the dev session. Skipped under the same // escape hatch as the locality guard — remote databases are on their own. -if (process.env.ALLOW_REMOTE_DB !== 'true' && process.env.ALLOW_REMOTE_DB !== '1') { +if (!isRemoteDbOverrideSet()) { const volumesClean = warnOnSharedDataVolumes() const healthy = await diagnoseDatabaseTarget({ label: 'guard-dev-db', diff --git a/scripts/import-projecao.mjs b/scripts/import-projecao.mjs index 38157280..50ed6cb2 100644 --- a/scripts/import-projecao.mjs +++ b/scripts/import-projecao.mjs @@ -37,15 +37,14 @@ import { randomBytes } from 'node:crypto' import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { loadCliEnv } from './lib/cli.mjs' -import { config as loadEnv } from 'dotenv' import { getPayload } from 'payload' import XLSX from 'xlsx' import { assertLocalDatabase } from './assert-local-database.mjs' -loadEnv({ path: '.env.local' }) -loadEnv({ path: '.env' }) +loadCliEnv() const __dirname = dirname(fileURLToPath(import.meta.url)) const repoRoot = join(__dirname, '..') diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs new file mode 100644 index 00000000..d5bbb6b8 --- /dev/null +++ b/scripts/lib/cli.mjs @@ -0,0 +1,90 @@ +/** + * Shared CLI skeleton for scripts/*.mjs (Pass 3 P3-H; the `scripts/lib/` home + * exists since B5 F2). `die`, `sha256`, the download→cache→sha256 prologue, + * `writeText`, the dotenv preamble and the local-host list were each spelled + * 5–7 times — the newest script copied the OLD shape, i.e. live drift. The + * convention guard in `codebaseConventions.unit.spec.ts` fails the build on + * any of these re-spelled outside this module. + */ +import { createHash } from 'node:crypto' +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import { config as loadEnv } from 'dotenv' + +/** Labelled `die` factory: `dieWithLabel('seed:tse')` → the script's `die`. */ +export const dieWithLabel = (label) => (message) => { + console.error(`\n[${label}] ${message}\n`) + process.exit(1) +} + +export const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex') + +/** The dotenv preamble every DB-touching script needs (.env.local wins over .env). */ +export const loadCliEnv = () => { + loadEnv({ path: '.env.local' }) + loadEnv({ path: '.env' }) +} + +/** Local database hosts — including the docker-compose service name. THE one list. */ +export const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres']) + +export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB' + +export const isRemoteDbOverrideSet = () => + process.env[ALLOW_REMOTE_DB_FLAG] === 'true' || process.env[ALLOW_REMOTE_DB_FLAG] === '1' + +/** + * download → cache → sha256 with provenance logging. `ext` picks the cache + * filename; when it is 'json' the parsed body rides along. `expectedSha256` + * fails hard on mismatch (provenance-pinned sources, e.g. TSE zips). + * `download` is injected (`downloadToBuffer` from src/lib/electionResultsZip.ts + * needs the script's tsx loader, so it cannot be imported here). + */ +export const ensureCachedDownload = async ({ + label, + key, + url, + ext, + cacheDir, + expectedSha256, + download, +}) => { + await mkdir(cacheDir, { recursive: true }) + const path = join(cacheDir, `${key}.${ext}`) + let buffer + try { + await access(path) + console.log(`[${label}] cache hit ${path}`) + buffer = await readFile(path) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + console.log(`[${label}] downloading ${url}`) + buffer = await download(url) + await writeFile(path, buffer) + console.log(`[${label}] saved ${path} (${buffer.length} bytes)`) + } + + const hash = sha256Hex(buffer) + if (expectedSha256 && hash !== expectedSha256) { + dieWithLabel(label)( + `SHA-256 mismatch for ${key}\n expected=${expectedSha256}\n actual=${hash}\n Delete ${path} and retry.`, + ) + } + + return { + url, + hash, + buffer, + // 'json' and 'geojson' both carry a JSON body. + ...(ext.endsWith('json') ? { json: JSON.parse(buffer.toString('utf8')) } : {}), + } +} + +/** Write a repo file with the directory walk and the byte-count log line. */ +export const writeRepoFile = async ({ label, root, relativePath, body }) => { + const path = join(root, relativePath) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, body) + console.log(`[${label}] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`) +} diff --git a/scripts/regenerate-municipality-catalog-snapshot.mjs b/scripts/regenerate-municipality-catalog-snapshot.mjs index 56ca1e06..65114856 100644 --- a/scripts/regenerate-municipality-catalog-snapshot.mjs +++ b/scripts/regenerate-municipality-catalog-snapshot.mjs @@ -1,5 +1,5 @@ -import { createHash } from 'node:crypto' import { writeFileSync } from 'node:fs' +import { sha256Hex } from './lib/cli.mjs' import { municipalityCatalog, ZONE_MUNICIPALITY_CITIES } from '../src/lib/municipalityCatalog.ts' @@ -28,7 +28,7 @@ const snapshot = { generatedFrom: 'src/lib/municipalityCatalog.ts', municipalityCount: entries.length, zoneMunicipalityCities: [...ZONE_MUNICIPALITY_CITIES], - identitySha256: createHash('sha256').update(rows).digest('hex'), + identitySha256: sha256Hex(rows), entries, } writeFileSync( diff --git a/scripts/seed-consent.mjs b/scripts/seed-consent.mjs index aac4a006..64f2c811 100644 --- a/scripts/seed-consent.mjs +++ b/scripts/seed-consent.mjs @@ -9,13 +9,12 @@ * ALLOW_REMOTE_DB=true pnpm db:seed:consent */ -import { config as loadEnv } from 'dotenv' import { getPayload } from 'payload' +import { loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' -loadEnv({ path: '.env.local' }) -loadEnv({ path: '.env' }) +loadCliEnv() assertLocalDatabase( 'seed:consent', diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs index c2d89f49..15c7040d 100644 --- a/scripts/seed-posts.mjs +++ b/scripts/seed-posts.mjs @@ -25,23 +25,19 @@ */ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical' import { del as blobDel } from '@vercel/blob' -import { config as loadEnv } from 'dotenv' import { JSDOM } from 'jsdom' import { getPayload } from 'payload' +import { loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' // Mirror Next.js precedence (.env.local wins over .env) without clobbering a // DATABASE_URL already set in the real environment. -loadEnv({ path: '.env.local' }) -loadEnv({ path: '.env' }) +loadCliEnv() const config = (await import('../src/payload.config.ts')).default -const die = (message) => { - console.error(`\n[seed:posts] ${message}\n`) - process.exit(1) -} +const die = dieWithLabel('seed:posts') // --------------------------------------------------------------------------- // Taxonomy (Section 7): the categories + control tag, and the per-article map. diff --git a/scripts/seed-tse-results.mjs b/scripts/seed-tse-results.mjs index cd5a1848..abe054f3 100644 --- a/scripts/seed-tse-results.mjs +++ b/scripts/seed-tse-results.mjs @@ -33,16 +33,13 @@ * TSE_CACHE_DIR=./data/tse pnpm db:seed:tse */ -import { config as loadEnv } from 'dotenv' -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { getPayload } from 'payload' +import { dieWithLabel, ensureCachedDownload, loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' -loadEnv({ path: '.env.local' }) -loadEnv({ path: '.env' }) +loadCliEnv() const config = (await import('../src/payload.config.ts')).default const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts') @@ -88,12 +85,7 @@ const csvNamesForYear = (year) => ({ candBr: `consulta_cand_${year}_BR.csv`, }) -const die = (message) => { - console.error(`\n[seed:tse] ${message}\n`) - process.exit(1) -} - -const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') +const die = dieWithLabel('seed:tse') const parseYearArg = () => { const explicit = process.argv.find((arg) => arg.startsWith('--year=')) @@ -107,31 +99,16 @@ const parseYearArg = () => { const cacheDir = () => process.env.TSE_CACHE_DIR || join(process.cwd(), 'data', 'tse') -const ensureCachedZip = async ({ key, url, expectedSha256 }) => { - const dir = cacheDir() - await mkdir(dir, { recursive: true }) - const path = join(dir, `${key}.zip`) - let buffer - try { - await access(path) - console.log(`[seed:tse] cache hit ${path}`) - buffer = await readFile(path) - } catch (error) { - if (error?.code !== 'ENOENT') throw error - console.log(`[seed:tse] downloading ${url}`) - buffer = await downloadToBuffer(url) - await writeFile(path, buffer) - console.log(`[seed:tse] saved ${path} (${buffer.length} bytes)`) - } - - const hash = sha256(buffer) - if (expectedSha256 && hash !== expectedSha256) { - die( - `SHA-256 mismatch for ${key}\n expected=${expectedSha256}\n actual=${hash}\n Delete ${path} and retry.`, - ) - } - return { key, url, buffer, hash } -} +const ensureCachedZip = ({ key, url, expectedSha256 }) => + ensureCachedDownload({ + label: 'seed:tse', + key, + url, + ext: 'zip', + cacheDir: cacheDir(), + expectedSha256, + download: downloadToBuffer, + }) const loadCsvFromZip = async (zipBuffer, fileName) => { const entry = await readZipEntry(zipBuffer, (name) => name.endsWith(fileName)) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index 12d055ad..6ff4c709 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -11,9 +11,14 @@ import { removeSupporterData, setSupporterVoteIntention, } from '@/app/(campaign)/campanha/actions/supporter' +import { SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys' import { checkboxFormValue, requiredRelationshipFormValue } from '@/lib/formData' import type { SupporterVoteIntention } from '@/lib/schemas/supporter' -import { supporterRemoveSchema, supporterVoteIntentionSchema } from '@/lib/schemas/supporter' +import { + SUPPORTER_STAFF_MESSAGE, + supporterRemoveSchema, + supporterVoteIntentionSchema, +} from '@/lib/schemas/supporter' import { mapCampaignFormActionError, type CampaignFormErrorState, @@ -32,8 +37,8 @@ export type SupporterRemoveFormState = { } const safeVoteIntentionMessages = [ - 'Consentimento de intenção de voto ainda não configurado.', - 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', + SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE, + SUPPORTER_STAFF_MESSAGE, ] as const const toMessageOnlyState = (mapped: CampaignFormErrorState): { message?: string } => { diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index eb48fec9..24b7587b 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -13,15 +13,18 @@ import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntent import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { campaignRoleLabels } from '@/utilities/campaignUserProfile' import { loadSupporterDetailConsentData, loadSupporterDetailPageData, SupporterNotFoundError, -} from '@/utilities/supporterPageData' -import { canAccessSupporterArea, supporterVoteIntentionLabels } from '@/utilities/supporterUi' -import { parseSupporterId } from '@/utilities/supporterViewModels' +} from '@/utilities/supporter/supporterPageData' +import { + canAccessSupporterArea, + supporterVoteIntentionLabels, +} from '@/utilities/supporter/supporterUi' +import { parseSupporterId } from '@/utilities/supporter/supporterViewModels' type SupporterDetailPageProps = { params: Promise<{ id: string }> @@ -37,8 +40,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag const supporterId = parseSupporterId(rawId) if (supporterId === null) notFound() - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') + const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })]) if (!canAccessSupporterArea(user.role)) redirect('/campanha') let supporter diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx index 97fa1023..a0c4c811 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx @@ -5,12 +5,10 @@ import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBa import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard' import { Button } from '@/components/ui/button' import { isCampaignCoordinator } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' export default async function ImportSupportersPage() { - const user = await getCampaignUser() - - if (!user) redirect('/campanha/login') + const user = await requireCampaignPageActor() if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores') return ( diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 0f5a6365..326236d4 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -3,10 +3,20 @@ import { revalidatePath } from 'next/cache' import { createSupporter } from '@/app/(campaign)/campanha/actions/supporter' +import { + SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE, + SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE, +} from '@/lib/campaignConsentKeys' import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData' import { sanitizeBrazilianPhoneInput } from '@/lib/phone' -import { supporterCreateSchema } from '@/lib/schemas/supporter' +import { + SUPPORTER_DUPLICATE_MESSAGE, + SUPPORTER_STAFF_MESSAGE, + SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE, + supporterCreateSchema, +} from '@/lib/schemas/supporter' import { runCampaignFormAction } from '@/utilities/campaignFormActionError' +import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant' export type SupporterFormState = { status?: 'success' @@ -27,12 +37,12 @@ type SupporterFormValues = { } const safeActionMessages = [ - 'Esta pessoa já está cadastrada como apoiador neste município.', - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', - 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', - 'Somente o Coordenador Geral pode cadastrar apoiadores sem município.', - 'Consentimento de cadastro de apoiador ainda não configurado.', - 'Consentimento de intenção de voto ainda não configurado.', + SUPPORTER_DUPLICATE_MESSAGE, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, + SUPPORTER_STAFF_MESSAGE, + SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE, + SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE, + SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE, ] as const export const createSupporterFormAction = async ( diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx index 4c4d680c..009d9112 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -7,15 +7,14 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge' import { SupporterForm } from '@/components/campaign/supporter/SupporterForm' import { Button } from '@/components/ui/button' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { campaignRoleLabels } from '@/utilities/campaignUserProfile' -import { loadSupporterCreatePageData } from '@/utilities/supporterPageData' -import { canAccessSupporterArea } from '@/utilities/supporterUi' +import { loadSupporterCreatePageData } from '@/utilities/supporter/supporterPageData' +import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi' export default async function NewSupporterPage() { - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })]) - if (!user) redirect('/campanha/login') if (!canAccessSupporterArea(user.role)) redirect('/campanha') const pageData = await loadSupporterCreatePageData(payload, user) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index b06e91fd..c5a1b384 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -17,26 +17,25 @@ import { SupporterList } from '@/components/campaign/supporter/SupporterList' import { SupporterListOverview } from '@/components/campaign/supporter/SupporterListOverview' import { Button } from '@/components/ui/button' import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { campaignRoleLabels } from '@/utilities/campaignUserProfile' -import { loadSupportersPageData } from '@/utilities/supporterPageData' +import { loadSupportersPageData } from '@/utilities/supporter/supporterPageData' import { buildSupporterFiltersKey, buildSupporterListHref, canAccessSupporterArea, getSupporterScopeLabel, -} from '@/utilities/supporterUi' -import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels' +} from '@/utilities/supporter/supporterUi' +import { toSupporterListItemViewModel } from '@/utilities/supporter/supporterViewModels' type SupportersPageProps = { searchParams: Promise> } export default async function SupportersPage({ searchParams }: SupportersPageProps) { - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })]) - if (!user) return null if (!canAccessSupporterArea(user.role)) redirect('/campanha') const rawSearchParams = await searchParams diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index 53aaff30..f84043d2 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -1,7 +1,7 @@ import config from '@payload-config' import { ArrowLeftIcon, MapPinIcon } from 'lucide-react' import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' @@ -12,8 +12,7 @@ import { Button } from '@/components/ui/button' import { formatBrazilianPhoneInput } from '@/lib/phone' import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' import { loadAdvisorDetail } from '@/utilities/advisorData' -import { isCampaignUnrestricted } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions' type AdvisorDetailPageProps = { @@ -25,9 +24,10 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro const advisorId = Number(rawId) if (!Number.isInteger(advisorId) || advisorId <= 0) notFound() - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignUnrestricted(user)) redirect('/campanha') + const [, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'unrestricted' }), + getPayload({ config }), + ]) const advisor = await loadAdvisorDetail(payload, advisorId) if (!advisor) notFound() diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index 550ceec1..9d949ac8 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -1,5 +1,4 @@ import config from '@payload-config' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable' @@ -15,9 +14,8 @@ import { loadAdvisorListPageData, parseAdvisorListParams, } from '@/utilities/advisorData' -import { isCampaignUnrestricted } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { createAdvisorFormAction, sendAdvisorPasswordResetFormAction, @@ -31,9 +29,10 @@ type AdvisorsPageProps = { export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) { const rawSearchParams = await searchParams - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignUnrestricted(user)) redirect('/campanha') + const [, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'unrestricted' }), + getPayload({ config }), + ]) const state = parseAdvisorListParams(rawSearchParams) const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([ diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx index faed4416..fbe402aa 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx @@ -5,10 +5,10 @@ import { ActivityResultForm } from '@/components/campaign/activity/ActivityResul import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { activityOriginLabels } from '@/lib/schemas/activity' import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand' import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' const currencyFormatter = new Intl.NumberFormat('pt-BR', { style: 'currency', diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx index 7d978858..6603c2d8 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx @@ -1,5 +1,5 @@ import config from '@payload-config' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { @@ -9,13 +9,12 @@ import { import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions' import { ActivityForm } from '@/components/campaign/activity/ActivityForm' import { ActivityNotFoundError, getActivityEditPageData } from '@/utilities/activityPageData' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions, loadOrganizationOptions, } from '@/utilities/campaignRelationOptions' -import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels' +import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels' type EditActivityPageProps = { params: Promise<{ slug: string }> @@ -24,12 +23,9 @@ type EditActivityPageProps = { export default async function EditActivityPage({ params }: EditActivityPageProps) { const [{ slug }, user, payload] = await Promise.all([ params, - getCampaignUser(), + requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }), getPayload({ config }), ]) - - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha/atividades') if (!slug) notFound() const canManageAdvisors = user.role === 'coordinator' diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index 142fd830..c00e8275 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -16,6 +16,7 @@ import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdat import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { activityKindLabels } from '@/lib/schemas/activity' import { getActivityDetailPageData } from '@/utilities/activityDetailPageData' import { @@ -27,8 +28,7 @@ import { resolveAccessibleActivityContext, } from '@/utilities/activityPageData' import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { ActivityOverviewTab } from './ActivityOverviewTab' @@ -44,12 +44,9 @@ export default async function ActivityDetailPage({ const [{ slug }, query, user, payload] = await Promise.all([ params, searchParams, - getCampaignUser(), + requireCampaignPageActor({ gate: 'staff' }), getPayload({ config }), ]) - - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') if (!slug) notFound() const activeTab = resolveActivityDetailTab(query) diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts b/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts index bcc666b2..7a8ba6f7 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts @@ -12,7 +12,7 @@ import { TOUR_MAX_STOPS_MESSAGE, TOUR_OUT_OF_SCOPE_MESSAGE, TOUR_STAFF_ONLY_MESSAGE, -} from '@/utilities/visitPlannerViews' +} from '@/utilities/visit/visitPlannerViews' const TOUR_SAFE_MESSAGES = [ TOUR_EMPTY_MESSAGE, diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx index 10e35233..eac45795 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx @@ -2,7 +2,6 @@ import config from '@payload-config' import { MapPinnedIcon } from 'lucide-react' import type { Metadata } from 'next' import Link from 'next/link' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' @@ -15,21 +14,20 @@ import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote' import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm' import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker' import { Button } from '@/components/ui/button' +import { formatBahiaDayLabel } from '@/lib/campaignTime' import { formatElectionNumber } from '@/lib/electionFormat' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { formatBahiaDayLabel } from '@/utilities/campaignTime' -import { buildMunicipalityDetailTabHref } from '@/utilities/municipalityDetailTabUi' -import { visitConditionLabels } from '@/utilities/visitEligibility' -import { loadVisitCandidates, loadVisitPlannerRegions } from '@/utilities/visitPlannerData' -import { buildTourComposerHref, parseTourComposerParams } from '@/utilities/visitPlannerUrl' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { buildMunicipalityDetailTabHref } from '@/utilities/municipality/municipalityDetailTabUi' +import { visitConditionLabels } from '@/utilities/visit/visitEligibility' +import { loadVisitCandidates, loadVisitPlannerRegions } from '@/utilities/visit/visitPlannerData' +import { buildTourComposerHref, parseTourComposerParams } from '@/utilities/visit/visitPlannerUrl' import { composeTourSuggestion, resolveTourStopRole, tourStopRoleActivityKind, tourSuggestionSlugs, type VisitCandidateViewModel, -} from '@/utilities/visitPlannerViews' +} from '@/utilities/visit/visitPlannerViews' import { createTourDraftsFormAction } from './formActions' @@ -86,9 +84,10 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag const rawSearchParams = await searchParams const { region } = parseTourComposerParams(rawSearchParams) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) return null - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const now = new Date() const [regions, bundle] = await Promise.all([ diff --git a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx index 354aa59a..507666bf 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx @@ -1,5 +1,4 @@ import config from '@payload-config' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { @@ -8,19 +7,18 @@ import { } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions' import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions' import { ActivityForm } from '@/components/campaign/activity/ActivityForm' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions, loadOrganizationOptions, } from '@/utilities/campaignRelationOptions' -import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels' +import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels' export default async function NewActivityPage() { - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha/atividades') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }), + getPayload({ config }), + ]) const canManageAdvisors = user.role === 'coordinator' const [municipalityOptions, organizationOptions, advisorOptions] = await Promise.all([ diff --git a/src/app/(campaign)/campanha/(app)/atividades/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/page.tsx index 9eddcdcd..5b1ae1ea 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/page.tsx @@ -23,9 +23,9 @@ import { } from '@/utilities/activityUi' import { toActivityListViewModel } from '@/utilities/activityViewModels' import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' -import { TOUR_COMPOSER_PATH } from '@/utilities/visitPlannerUrl' +import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl' type ActivityListPageProps = { searchParams: Promise> @@ -36,9 +36,10 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag const canonicalUrl = resolveActivityListUrl(rawSearchParams) if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) return null - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const now = new Date() const [{ result, state }, municipalityOptions] = await Promise.all([ diff --git a/src/app/(campaign)/campanha/(app)/conceitos/page.tsx b/src/app/(campaign)/campanha/(app)/conceitos/page.tsx index 56d121f1..9a0519e1 100644 --- a/src/app/(campaign)/campanha/(app)/conceitos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/conceitos/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next' -import { redirect } from 'next/navigation' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { @@ -7,8 +6,7 @@ import { campaignConceptsByCategory, type CampaignIntelligenceConcept, } from '@/lib/campaignIntelligenceConcepts' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' export const metadata: Metadata = { title: 'Conceitos de inteligência | Campanha', @@ -63,9 +61,7 @@ const ConceptSection = ({ concept }: { concept: CampaignIntelligenceConcept }) = * goals, field ceiling) that a `leader` never sees — M4's dual vocabulary. */ export default async function CampaignConceptsPage() { - const user = await getCampaignUser() - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + await requireCampaignPageActor({ gate: 'staff' }) return ( diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts index e7603511..8bf9d62e 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/formActions.ts @@ -13,20 +13,23 @@ import { requiredFormText, requiredRelationshipFormValue, } from '@/lib/formData' -import { campaignDemandStatuses, type CampaignDemandStatus } from '@/lib/schemas/campaignDemand' +import { + CAMPAIGN_DEMAND_COST_STAFF_MESSAGE, + CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE, + CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES, + CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES, + campaignDemandStatuses, + type CampaignDemandStatus, +} from '@/lib/schemas/campaignDemand' import { getCampaignActionContext } from '@/utilities/campaignActionContext' import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -const INVALID_STATUS_MESSAGE = 'Status de demanda inválido.' +const INVALID_STATUS_MESSAGE = CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE -const transitionSafeMessages = [ - 'Somente a coordenação e a assessoria movem demandas.', - 'Demandas escaladas são decididas pelo Coordenador Geral.', - INVALID_STATUS_MESSAGE, -] as const +const transitionSafeMessages = CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES export const transitionDemandFormAction = async ( _state: CampaignFormActionState, @@ -74,7 +77,7 @@ export const setDemandCostFormAction = async ( revalidatePath('/campanha/demandas/[slug]', 'page') return { message: 'Custo registrado.' } }, - safeMessages: ['Somente a coordenação e a assessoria registram custos.'], + safeMessages: [CAMPAIGN_DEMAND_COST_STAFF_MESSAGE], genericMessage: 'Não foi possível registrar o custo. Tente novamente.', }) @@ -95,11 +98,6 @@ export const attachDemandReceiptFormAction = async ( revalidatePath('/campanha/demandas/[slug]', 'page') return { message: 'Comprovante anexado.' } }, - safeMessages: [ - 'Envie uma imagem (JPEG, PNG, WebP) ou PDF.', - 'O arquivo enviado está vazio.', - 'O comprovante deve ter no máximo 10 MB.', - 'Somente a coordenação e a assessoria anexam comprovantes.', - ], + safeMessages: CAMPAIGN_DEMAND_RECEIPT_SAFE_MESSAGES, genericMessage: 'Não foi possível anexar o comprovante. Tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index ac8e039a..8233bc98 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -1,7 +1,7 @@ import config from '@payload-config' import { ArrowLeftIcon, PaperclipIcon } from 'lucide-react' import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard' @@ -13,9 +13,9 @@ import { campaignDemandStatusLabels, type CampaignDemandStatus, } from '@/lib/schemas/campaignDemand' -import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { loadDemandDetail } from '@/utilities/campaignDemandData' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { attachDemandReceiptFormAction, setDemandCostFormAction, @@ -49,9 +49,10 @@ const currencyFormatter = new Intl.NumberFormat('pt-BR', { export default async function DemandDetailPage({ params }: DemandDetailPageProps) { const { slug } = await params - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const demand = await loadDemandDetail(payload, user, slug) if (!demand) notFound() diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts index f55ee0f2..4f93e504 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts @@ -31,6 +31,5 @@ export const createDemandFormAction = async ( }) }, redirectTo: (demand) => `/campanha/demandas/${demand.slug}`, - safeMessages: ['Somente a coordenação e a assessoria podem abrir demandas.'], genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index c5248afa..ca266051 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -1,15 +1,13 @@ import config from '@payload-config' import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { DemandForm } from '@/components/campaign/demand/DemandForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadActivityOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createDemandFormAction } from './formActions' @@ -19,12 +17,10 @@ type NewDemandPageProps = { export default async function NewDemandPage({ searchParams }: NewDemandPageProps) { const [user, payload, query] = await Promise.all([ - getCampaignUser(), + requireCampaignPageActor({ gate: 'staff' }), getPayload({ config }), searchParams, ]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') const [municipalityOptions, activityOptions] = await Promise.all([ loadMunicipalityOptions(payload, user), diff --git a/src/app/(campaign)/campanha/(app)/demandas/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/page.tsx index 489a797e..3d227137 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/page.tsx @@ -1,7 +1,6 @@ import config from '@payload-config' import { InboxIcon, PlusIcon } from 'lucide-react' import Link from 'next/link' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignFilterChips } from '@/components/campaign/shared/CampaignFilterChips' @@ -21,8 +20,6 @@ import { campaignDemandStatuses, type CampaignDemandStatus, } from '@/lib/schemas/campaignDemand' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { buildDemandListHref, @@ -30,6 +27,7 @@ import { parseDemandListParams, type DemandRowViewModel, } from '@/utilities/campaignDemandData' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' type DemandsPageProps = { searchParams: Promise> @@ -96,9 +94,10 @@ const demandColumns: Array> = [ export default async function DemandsPage({ searchParams }: DemandsPageProps) { const rawSearchParams = await searchParams - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const state = parseDemandListParams(rawSearchParams) const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData( diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx index 18b04b73..bd9163e6 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx @@ -1,15 +1,14 @@ import config from '@payload-config' import { ArrowLeftIcon, HandshakeIcon, MapPinIcon } from 'lucide-react' import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyDetail } from '@/utilities/stateDeputyData' import { updateStateDeputyFormAction } from './formActions' @@ -19,9 +18,10 @@ type StateDeputyDetailPageProps = { export default async function StateDeputyDetailPage({ params }: StateDeputyDetailPageProps) { const { slug } = await params - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const stateDeputy = await loadStateDeputyDetail(payload, user, slug) if (!stateDeputy) notFound() diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index 35f1972e..dbdc8331 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -7,17 +7,17 @@ import { requiredFormBoolean, requiredRelationshipFormValue, } from '@/lib/formData' -import { LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE } from '@/lib/schemas/leadership' +import { + LEADERSHIP_STAFF_MESSAGE, + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, +} from '@/lib/schemas/leadership' import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy' import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -const safeMessages = [ - 'Somente a coordenação e a assessoria podem gerenciar lideranças.', - LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, -] as const +const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const /** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */ export const setLeadershipStateDeputyMembershipFormAction = async ( diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx index bcb11f06..6e09c2e7 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx @@ -1,18 +1,14 @@ import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' -import { redirect } from 'next/navigation' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { createStateDeputyFormAction } from './formActions' export default async function NewStateDeputyPage() { - const user = await getCampaignUser() - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + await requireCampaignPageActor({ gate: 'staff' }) return ( diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index 9f7ee324..97e26a31 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -34,11 +34,11 @@ import { } from '@/components/ui/Empty' import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { cn } from '@/lib/utils' -import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions' -import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex' +import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { loadStateDeputyListPageData, type StateDeputyRowViewModel, @@ -175,9 +175,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP const canonicalUrl = resolveStateDeputyListUrl(rawSearchParams) if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const [ { rows, totalDocs, totalPages, filterFacets }, diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 1245f9b2..f85559f0 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -22,8 +22,8 @@ import { Toaster } from '@/components/ui/Toaster' import { deviceLabelFromUserAgent } from '@/lib/deviceLabel' import { getCampaignUser } from '@/utilities/campaignAuth' import { campaignUserShellView } from '@/utilities/campaignUserProfile' -import { loadCampaignPasskeys } from '@/utilities/campaignWebAuthnCeremony' -import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig' +import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony' +import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig' export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) { const user = await getCampaignUser() diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index fb4a72ea..615b07cc 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -1,7 +1,7 @@ import config from '@payload-config' import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { LeadershipInviteButtons } from '@/components/campaign/invite/LeadershipInviteButtons' @@ -11,14 +11,13 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions, loadOrganizationOptions, loadStateDeputyOptions, } from '@/utilities/campaignRelationOptions' -import { loadLeadershipDetail } from '@/utilities/leadershipData' +import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData' import { updateLeadershipInternalFormAction } from './formActions' type LeadershipDetailPageProps = { @@ -29,9 +28,10 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP const { id } = await params if (!/^[1-9]\d*$/.test(id)) notFound() - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const leadership = await loadLeadershipDetail(payload, user, Number(id)) if (!leadership) notFound() diff --git a/src/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages.ts b/src/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages.ts index c4662c8f..21b6bbb8 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages.ts @@ -1,4 +1,4 @@ +import { LEADERSHIP_STAFF_MESSAGE } from '@/lib/schemas/leadership' + /** Shared form-error copy for leadership staff edits — kept out of `'use server'` files. */ -export const leadershipStaffEditSafeMessages = [ - 'Somente a coordenação e a assessoria podem gerenciar lideranças.', -] as const +export const leadershipStaffEditSafeMessages = [LEADERSHIP_STAFF_MESSAGE] as const diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts index 2ab324b0..6a51cd09 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -4,6 +4,7 @@ import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app) import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership' import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData' import { + LEADERSHIP_DUPLICATE_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, leadershipSectors, leadershipSupportStatuses, @@ -12,12 +13,13 @@ import { runCampaignRedirectFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' +import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant' const safeMessages = [ - 'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novos municípios.', + LEADERSHIP_DUPLICATE_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, ...leadershipStaffEditSafeMessages, - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', + CONTACT_PHONE_AMBIGUOUS_MESSAGE, ] as const export const createLeadershipFormAction = async ( diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx index 7450841e..67a5a5ca 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx @@ -1,14 +1,12 @@ import config from '@payload-config' import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { LeadershipForm } from '@/components/campaign/leadership/LeadershipForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions, loadOrganizationOptions, @@ -22,9 +20,10 @@ type NewLeadershipPageProps = { export default async function NewLeadershipPage({ searchParams }: NewLeadershipPageProps) { const rawSearchParams = await searchParams - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const [municipalityOptions, organizationOptions, stateDeputyOptions] = await Promise.all([ loadMunicipalityOptions(payload, user), diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 51a1c0ff..f57aed67 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -39,6 +39,7 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/Empty' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { resolvedPortfolioEntriesById, type MunicipalityPortfolioIndexEntry, @@ -46,19 +47,21 @@ import { import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas/leadership' import { cn } from '@/lib/utils' -import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' import { formatRelativeAge } from '@/utilities/formatRelativeAge' -import { loadLeadershipListPageData, type LeadershipRowViewModel } from '@/utilities/leadershipData' -import { leadershipAccessFilterLabels } from '@/utilities/leadershipLabels' +import { + loadLeadershipListPageData, + type LeadershipRowViewModel, +} from '@/utilities/leadership/leadershipData' +import { leadershipAccessFilterLabels } from '@/utilities/leadership/leadershipLabels' import { buildLeadershipFilterHref, clearLeadershipListFilters, type LeadershipFilterOption, -} from '@/utilities/leadershipListFilters' +} from '@/utilities/leadership/leadershipListFilters' import { buildLeadershipListHref, formatLeadershipListSortSummary, @@ -66,9 +69,9 @@ import { resolveLeadershipListSort, resolveLeadershipListUrl, type LeadershipListState, -} from '@/utilities/leadershipListUrl' -import { leadershipSectorLabels } from '@/utilities/leadershipUi' -import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex' +} from '@/utilities/leadership/leadershipListUrl' +import { leadershipSectorLabels } from '@/utilities/leadership/leadershipUi' +import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { setLeadershipMunicipalitiesFormAction, @@ -316,9 +319,10 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP const canonicalUrl = resolveLeadershipListUrl(rawSearchParams) if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const [ { rows, totalDocs, totalPages, filterFacets }, diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx index 34dae46b..dc700570 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx @@ -15,25 +15,25 @@ import { SuggestionsPanel } from '@/components/campaign/suggestion/SuggestionsPa import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import type { getCampaignUser } from '@/utilities/campaignAuth' import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' -import { loadMunicipalityLeaderships } from '@/utilities/leadershipData' +import { loadMunicipalityLeaderships } from '@/utilities/leadership/leadershipData' import { loadMunicipalityCandidateComparison, MAX_COMPARISON_CANDIDATES, -} from '@/utilities/municipalityCandidateComparison' -import type { MunicipalityDetailSearchParams } from '@/utilities/municipalityDetailTabUi' -import { loadMunicipalityDossierData } from '@/utilities/municipalityDossierData' -import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography' -import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline' -import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount' -import type { getMunicipalityDetailViewModel } from '@/utilities/municipalityPageData' -import { formatSilenceAgeLabel } from '@/utilities/municipalitySignal' -import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers' +} from '@/utilities/municipality/municipalityCandidateComparison' +import type { MunicipalityDetailSearchParams } from '@/utilities/municipality/municipalityDetailTabUi' +import { loadMunicipalityDossierData } from '@/utilities/municipality/municipalityDossierData' +import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography' +import { loadMunicipalityElectoralBaseline } from '@/utilities/municipality/municipalityElectoralBaseline' +import { loadMunicipalityGoalAccount } from '@/utilities/municipality/municipalityGoalAccount' +import type { getMunicipalityDetailViewModel } from '@/utilities/municipality/municipalityPageData' +import { formatSilenceAgeLabel } from '@/utilities/municipality/municipalitySignal' +import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' import { loadMunicipalityUpdatesFeed, parseMunicipalityUpdateFeedParams, -} from '@/utilities/municipalityUpdatePageData' -import type { loadAdvisorSummaries } from '@/utilities/municipalityViewModels' -import { loadMunicipalityVisitEligibility } from '@/utilities/visitPlannerData' +} from '@/utilities/municipality/municipalityUpdatePageData' +import type { loadAdvisorSummaries } from '@/utilities/municipality/municipalityViewModels' +import { loadMunicipalityVisitEligibility } from '@/utilities/visit/visitPlannerData' import { loadMunicipalityPledges } from '@/utilities/votePledgeData' import { aggregateMunicipalityPledgesFromRows, diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts index 699fe495..88f49e7e 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts @@ -13,7 +13,7 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { municipalityStaffEditSafeMessages } from '../../municipalityStaffEditMessages' export const updateMunicipalityStrategyFormAction = async ( diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx index 36143f10..ba980fe2 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx @@ -8,15 +8,14 @@ import { MunicipalityAdvisorsForm } from '@/components/campaign/municipality/Mun import { MunicipalityStrategyForm } from '@/components/campaign/municipality/MunicipalityStrategyForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' import { getMunicipalityDetailViewModel, MunicipalityNotFoundError, resolveAccessibleMunicipalityContext, -} from '@/utilities/municipalityPageData' -import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels' +} from '@/utilities/municipality/municipalityPageData' +import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels' import { assignMunicipalityAdvisorsFormAction, setMunicipalityExpectedVotesFormAction, @@ -30,9 +29,10 @@ type MunicipalityEditPageProps = { export default async function MunicipalityEditPage({ params }: MunicipalityEditPageProps) { const { slug } = await params - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect(`/campanha/municipios/${slug}`) + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff', redirectTo: `/campanha/municipios/${slug}` }), + getPayload({ config }), + ]) let context try { diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx index ac7b9467..6112a912 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx @@ -1,6 +1,6 @@ import config from '@payload-config' import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { Suspense } from 'react' @@ -8,22 +8,21 @@ import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitT import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' -import { isCampaignLeader } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { resolveMunicipalityDetailTab, type MunicipalityDetailSearchParams, -} from '@/utilities/municipalityDetailTabUi' +} from '@/utilities/municipality/municipalityDetailTabUi' import { formatMunicipalityGeographyLabel, municipalityKindLabels, -} from '@/utilities/municipalityLabels' +} from '@/utilities/municipality/municipalityLabels' import { getMunicipalityDetailViewModel, MunicipalityNotFoundError, resolveAccessibleMunicipalityContext, -} from '@/utilities/municipalityPageData' -import { loadAdvisorSummaries } from '@/utilities/municipalityViewModels' +} from '@/utilities/municipality/municipalityPageData' +import { loadAdvisorSummaries } from '@/utilities/municipality/municipalityViewModels' import { DossierTab, @@ -46,9 +45,10 @@ export default async function MunicipalityDetailPage({ searchParams, }: MunicipalityDetailPageProps) { const [{ slug }, rawSearchParams] = await Promise.all([params, searchParams]) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (isCampaignLeader(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'noLeader' }), + getPayload({ config }), + ]) let context try { diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts index dcaf37e7..db24118f 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts @@ -2,6 +2,8 @@ import { revalidatePath } from 'next/cache' +import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives' + import { declareVotes, estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge' import { nullableFormText, @@ -9,16 +11,16 @@ import { requiredRelationshipFormValue, voteEstimateScenarioFromForm, } from '@/lib/formData' +import { + VOTE_PLEDGE_DECLARE_SAFE_MESSAGES, + VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE, +} from '@/lib/schemas/votePledge' import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -const declareSafeMessages = [ - 'Somente a coordenação e a assessoria registram votos declarados.', - 'A liderança precisa estar vinculada ao município para registrar votos declarados nele.', - 'Informe a liderança da declaração.', -] as const +const declareSafeMessages = VOTE_PLEDGE_DECLARE_SAFE_MESSAGES export const declareVotesFormAction = async ( _state: CampaignFormActionState, @@ -30,7 +32,7 @@ export const declareVotesFormAction = async ( const leadership = requiredRelationshipFormValue(formData, 'leadershipId') const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', { minimum: 0, - maximum: 1_000_000, + maximum: MAX_VOTE_COUNT, }) await declareVotes({ municipality, leadership, declaredVotes }) @@ -60,7 +62,7 @@ export const estimateVotesFormAction = async ( revalidatePath('/campanha/municipios/[slug]', 'page') return { message: 'Estimativa registrada.' } }, - safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'], + safeMessages: [VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE], genericMessage: 'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts index d7c44441..871fb6a7 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts @@ -2,10 +2,10 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality' +import { uniqueRelationshipIds } from '@/lib/relationship' import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality' import { positiveRelationshipId } from '@/lib/schemas/primitives' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' -import { uniqueRelationshipIds } from '@/utilities/relationship' import type { MunicipalityListAdvisorsResponse } from './types' 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 504eadc5..f461e6b9 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts @@ -7,13 +7,14 @@ import { positiveRelationshipId } from '@/lib/schemas/primitives' import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' +import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives' import type { MunicipalityListExpectedVotesResponse } from './types' export type { MunicipalityListExpectedVotesResponse } from './types' export const dynamic = 'force-dynamic' -const optionalEstimate = z.number().int().min(0).max(1_000_000).nullable() +const optionalEstimate = z.number().int().min(0).max(MAX_VOTE_COUNT).nullable() /** Unordered draft body — setMunicipalityExpectedVotes normalizes before persist. */ const bodySchema = z.object({ diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index d90feb9b..3a7a129f 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -24,7 +24,7 @@ import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { municipalityStaffEditSafeMessages } from './municipalityStaffEditMessages' diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index edc78732..6078aec1 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -16,23 +16,25 @@ import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBa import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { isCampaignCoordinator, - isCampaignLeader, isCampaignStaff, isCampaignUnrestricted, } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' -import { getCampaignScopeLabel } from '@/utilities/municipalityLabels' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { getCampaignScopeLabel } from '@/utilities/municipality/municipalityLabels' import { buildMunicipalityListVisitHref, buildMunicipalityListVisitLabel, -} from '@/utilities/municipalityListFilters' +} from '@/utilities/municipality/municipalityListFilters' import { buildMunicipalityListHref, resolveMunicipalityListUrl, -} from '@/utilities/municipalityListUrl' -import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData' -import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels' +} from '@/utilities/municipality/municipalityListUrl' +import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData' +import { + getEligibleAdvisorOptions, + loadAdvisorSummaries, +} from '@/utilities/municipality/municipalityViewModels' import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions' type MunicipalitiesPageProps = { @@ -44,10 +46,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie const canonicalUrl = resolveMunicipalityListUrl(rawSearchParams) if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - - if (!user) return null - if (isCampaignLeader(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'noLeader' }), + getPayload({ config }), + ]) const isStaffView = isCampaignStaff(user) const isCoordinator = isCampaignCoordinator(user) const canMoveEngagementLevel = isCampaignUnrestricted(user) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts index 0dbda46e..db89839a 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts @@ -9,7 +9,11 @@ import { repeatedRelationshipFormValues, requiredRelationshipFormValue, } from '@/lib/formData' -import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization' +import { + ORGANIZATION_STAFF_MESSAGE, + organizationKinds, + type OrganizationKind, +} from '@/lib/schemas/organization' import { runCampaignFormAction, type CampaignFormActionState, @@ -33,7 +37,7 @@ export const updateOrganizationFormAction = async ( revalidatePath('/campanha/organizacoes/[slug]', 'page') return { message: 'Organização atualizada.' } }, - safeMessages: ['Somente a coordenação e a assessoria gerenciam organizações.'], + safeMessages: [ORGANIZATION_STAFF_MESSAGE], genericMessage: 'Não foi possível salvar a organização. Verifique seu acesso e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx index 6b2a904a..da7fdabe 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx @@ -1,7 +1,7 @@ import config from '@payload-config' import { ArrowLeftIcon, CalendarDaysIcon, HandshakeIcon } from 'lucide-react' import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' +import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge' @@ -10,8 +10,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { organizationKindLabels } from '@/lib/schemas/organization' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { loadOrganizationDetail } from '@/utilities/organizationData' import { updateOrganizationFormAction } from './formActions' @@ -24,9 +23,10 @@ const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }) export default async function OrganizationDetailPage({ params }: OrganizationDetailPageProps) { const { slug } = await params - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const organization = await loadOrganizationDetail(payload, user, slug) if (!organization) notFound() diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts index c2f9f12a..9c034b7d 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts @@ -2,7 +2,12 @@ import { createOrganization } from '@/app/(campaign)/campanha/actions/organization' import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData' -import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization' +import { + ORGANIZATION_CONFLICT_MESSAGE, + ORGANIZATION_STAFF_MESSAGE, + organizationKinds, + type OrganizationKind, +} from '@/lib/schemas/organization' import { runCampaignRedirectFormAction, type CampaignFormActionState, @@ -25,10 +30,7 @@ export const createOrganizationFormAction = async ( }) }, redirectTo: (organization) => `/campanha/organizacoes/${organization.slug}`, - safeMessages: [ - 'Já existe uma organização com este nome.', - 'Somente a coordenação e a assessoria gerenciam organizações.', - ], + safeMessages: [ORGANIZATION_CONFLICT_MESSAGE, ORGANIZATION_STAFF_MESSAGE], genericMessage: 'Não foi possível cadastrar a organização. Verifique os dados e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx index 794384ac..c8dc3eb3 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx @@ -1,21 +1,20 @@ import config from '@payload-config' import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Button } from '@/components/ui/button' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createOrganizationFormAction } from './formActions' export default async function NewOrganizationPage() { - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const municipalityOptions = await loadMunicipalityOptions(payload, user) diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx index 0e45bd70..861ed744 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx @@ -1,7 +1,6 @@ import config from '@payload-config' import { PlusIcon, SearchXIcon } from 'lucide-react' import Link from 'next/link' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' @@ -16,9 +15,8 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { organizationKindLabels } from '@/lib/schemas/organization' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { buildOrganizationListHref, loadOrganizationListPageData, @@ -65,9 +63,10 @@ const organizationColumns: Array> export default async function OrganizationsPage({ searchParams }: OrganizationsPageProps) { const rawSearchParams = await searchParams - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - if (!user) redirect('/campanha/login') - if (!isCampaignStaff(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) const state = parseOrganizationListParams(rawSearchParams) const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state) @@ -97,6 +96,12 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP placeholder="Buscar por nome…" initialQuery={state.q ?? ''} basePath="/campanha/organizacoes" + // Canonical serialization of the ACTIVE filters minus q/page, so a + // search submit preserves `?kind=` instead of silently dropping it. + filterParams={buildOrganizationListHref({ ...state, q: undefined }, 1).replace( + /^\/campanha\/organizacoes\??/, + '', + )} /> diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index f9ce61d4..33264cc4 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -1,5 +1,4 @@ import config from '@payload-config' -import { redirect } from 'next/navigation' import { getPayload, type Payload } from 'payload' import { Suspense } from 'react' @@ -13,12 +12,12 @@ import { } from '@/components/campaign/suggestion/SuggestionsPanel' import type { CampaignUser } from '@/payload-types' import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignDashboardData } from '@/utilities/campaignDashboardData' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData' -import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData' -import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers' +import { loadMunicipalityMapBundle } from '@/utilities/municipality/municipalityMapData' +import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' import { resolveSuggestionFormAction } from './suggestionFormActions' @@ -30,8 +29,7 @@ type CampaignHomePageProps = { export default async function CampaignHomePage({ searchParams }: CampaignHomePageProps) { const rawSearchParams = await searchParams - const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) - if (!user) redirect('/campanha/login') + const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()]) if (isCampaignLeader(user)) { const view = await loadLeaderContactsPageData(payload, user) diff --git a/src/app/(campaign)/campanha/(app)/perfil/page.tsx b/src/app/(campaign)/campanha/(app)/perfil/page.tsx index bb574675..b5cfbbf6 100644 --- a/src/app/(campaign)/campanha/(app)/perfil/page.tsx +++ b/src/app/(campaign)/campanha/(app)/perfil/page.tsx @@ -1,16 +1,15 @@ import config from '@payload-config' import type { Metadata } from 'next' import { headers } from 'next/headers' -import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard' import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProfileSettings' import { deviceLabelFromUserAgent } from '@/lib/deviceLabel' -import { getCampaignUser } from '@/utilities/campaignAuth' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { campaignUserShellView } from '@/utilities/campaignUserProfile' -import { loadCampaignPasskeys } from '@/utilities/campaignWebAuthnCeremony' -import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig' +import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony' +import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig' export const metadata: Metadata = { title: 'Meu perfil | Campanha', @@ -25,11 +24,7 @@ type CampaignProfilePageProps = { } export default async function CampaignProfilePage({ searchParams }: CampaignProfilePageProps) { - const [user, params] = await Promise.all([getCampaignUser(), searchParams]) - - if (!user) { - redirect('/campanha/login') - } + const [user, params] = await Promise.all([requireCampaignPageActor(), searchParams]) const payload = await getPayload({ config }) const [passkeys, relyingParty, requestHeaders] = await Promise.all([ diff --git a/src/app/(campaign)/campanha/(app)/territorios/page.tsx b/src/app/(campaign)/campanha/(app)/territorios/page.tsx index 94f52385..d1becc45 100644 --- a/src/app/(campaign)/campanha/(app)/territorios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/territorios/page.tsx @@ -10,12 +10,14 @@ import { CampaignListResults, } from '@/components/campaign/shared/CampaignListPending' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' -import { isCampaignLeader } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' -import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview' -import { resolveTerritoryListSort, resolveTerritoryListUrl } from '@/utilities/territoryListUrl' -import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territoryOverview' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadTerritoryOverview } from '@/utilities/territory/loadTerritoryOverview' +import { + resolveTerritoryListSort, + resolveTerritoryListUrl, +} from '@/utilities/territory/territoryListUrl' +import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territory/territoryOverview' type TerritoriesPageProps = { searchParams: Promise> @@ -26,10 +28,10 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP const resolvedUrl = resolveTerritoryListUrl(rawSearchParams) if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - - if (!user) return null - if (isCampaignLeader(user)) redirect('/campanha') + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'noLeader' }), + getPayload({ config }), + ]) const allRows = await loadTerritoryOverview(payload, user) const columnVisibility = await readCampaignColumnVisibility('territorios') diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts index 8ac8b4a8..709cc53f 100644 --- a/src/app/(campaign)/campanha/actions/activity.ts +++ b/src/app/(campaign)/campanha/actions/activity.ts @@ -3,6 +3,12 @@ import type { Payload } from 'payload' import { + ACTIVITY_RESULT_REQUIRED_MESSAGE, + ACTIVITY_RESULT_STAFF_MESSAGE, + ACTIVITY_RESULT_TOO_LONG_MESSAGE, + ACTIVITY_TASK_NOT_FOUND_MESSAGE, + ACTIVITY_UPDATE_BODY_REQUIRED_MESSAGE, + ACTIVITY_UPDATE_BODY_TOO_LONG_MESSAGE, activityCreateSchema, activityDemandDraftsSchema, activityUpdateSchema, @@ -23,7 +29,7 @@ import { TOUR_MAX_STOPS_MESSAGE, TOUR_OUT_OF_SCOPE_MESSAGE, TOUR_STAFF_ONLY_MESSAGE, -} from '@/utilities/visitPlannerViews' +} from '@/utilities/visit/visitPlannerViews' const MAX_ACTIVITY_UPDATE_BODY_LENGTH = 4000 const MAX_ACTIVITY_RESULT_SUMMARY_LENGTH = 6000 @@ -246,7 +252,7 @@ const toggleActivityTaskRecord = async ( const tasks = activity.tasks ?? [] const taskIndex = tasks.findIndex((task) => task.id === taskId) - if (taskIndex === -1) throw new Error('Tarefa não encontrada.') + if (taskIndex === -1) throw new Error(ACTIVITY_TASK_NOT_FOUND_MESSAGE) const nextTasks = tasks.map((task, index) => (index === taskIndex ? { ...task, done } : task)) @@ -271,9 +277,9 @@ const appendActivityUpdateRecord = async ( body: string, ) => { const trimmedBody = body.trim() - if (!trimmedBody) throw new Error('Informe o texto da atualização.') + if (!trimmedBody) throw new Error(ACTIVITY_UPDATE_BODY_REQUIRED_MESSAGE) if (trimmedBody.length > MAX_ACTIVITY_UPDATE_BODY_LENGTH) { - throw new Error('Atualização muito longa. Reduza o texto e tente novamente.') + throw new Error(ACTIVITY_UPDATE_BODY_TOO_LONG_MESSAGE) } return withPayloadTransaction( @@ -317,9 +323,9 @@ const registerActivityResult = async ( mediaIDs: number[], ) => { const trimmedSummary = resultSummary.trim() - if (!trimmedSummary) throw new Error('Informe o resultado da atividade.') + if (!trimmedSummary) throw new Error(ACTIVITY_RESULT_REQUIRED_MESSAGE) if (trimmedSummary.length > MAX_ACTIVITY_RESULT_SUMMARY_LENGTH) { - throw new Error('Resultado muito longo. Reduza o texto e tente novamente.') + throw new Error(ACTIVITY_RESULT_TOO_LONG_MESSAGE) } return withPayloadTransaction( @@ -327,7 +333,7 @@ const registerActivityResult = async ( async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) if (!isCampaignStaff(currentActor)) { - throw new Error('Apenas a equipe da campanha pode registrar o resultado da atividade.') + throw new Error(ACTIVITY_RESULT_STAFF_MESSAGE) } return payload.update({ diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts index 522b88ca..4149f3fe 100644 --- a/src/app/(campaign)/campanha/actions/advisor.ts +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -6,8 +6,11 @@ import { revalidatePath } from 'next/cache' import type { Payload } from 'payload' import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership' +import { uniqueRelationshipIds } from '@/lib/relationship' import { ADVISOR_EMAIL_CONFLICT_MESSAGE, + ADVISOR_ROLE_REQUIRED_MESSAGE, + ADVISOR_SELF_ACCOUNT_MESSAGE, ADVISOR_UNRESTRICTED_MESSAGE, PLACEHOLDER_RESET_MESSAGE, advisorCreateSchema, @@ -30,10 +33,9 @@ import { isCampaignEmailConfigured, } from '@/utilities/campaignPasswordReset' import { hookFilledCreateData } from '@/utilities/hookFilledData' -import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { uniqueRelationshipIds } from '@/utilities/relationship' const revalidateAdvisorDetailPath = (advisorId: number) => { revalidatePath(`/campanha/assessores/${advisorId}`, 'page') @@ -67,10 +69,10 @@ const assertTargetAdvisor = async ( }) if (target.role !== 'advisor') { - throw new Error('Só é possível gerenciar contas com papel de Assessor nesta tela.') + throw new Error(ADVISOR_ROLE_REQUIRED_MESSAGE) } if (String(target.id) === String(actorId)) { - throw new Error('Use Meu perfil para alterar a própria conta.') + throw new Error(ADVISOR_SELF_ACCOUNT_MESSAGE) } return target @@ -167,7 +169,7 @@ export const setAdvisorMunicipalitiesBatchRecord = async ( input: AdvisorMunicipalitiesBatchInput, ) => { const { advisorId, municipalityIds, assigned } = advisorMunicipalitiesBatchSchema.parse(input) - const uniqueMunicipalityIds = [...new Set(municipalityIds)].sort((left, right) => left - right) + const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right) return withPayloadTransaction( payload, diff --git a/src/app/(campaign)/campanha/actions/demand.ts b/src/app/(campaign)/campanha/actions/demand.ts index 507c15d7..e5746786 100644 --- a/src/app/(campaign)/campanha/actions/demand.ts +++ b/src/app/(campaign)/campanha/actions/demand.ts @@ -2,6 +2,14 @@ import type { Payload } from 'payload' +import { + CAMPAIGN_DEMAND_COST_STAFF_MESSAGE, + CAMPAIGN_DEMAND_RECEIPT_EMPTY_MESSAGE, + CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE, + CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE, + CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE, + CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE, +} from '@/lib/schemas/campaignDemand' import { campaignDemandCostSchema, campaignDemandCreateSchema, @@ -60,7 +68,7 @@ export const transitionCampaignDemandRecord = async ( async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) if (!isCampaignStaff(currentActor)) { - throw new Error('Somente a coordenação, a assessoria e o candidato movem demandas.') + throw new Error(CAMPAIGN_DEMAND_TRANSITION_STAFF_MESSAGE) } await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`]) @@ -95,7 +103,7 @@ export const setCampaignDemandCostRecord = async ( async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) if (!isCampaignStaff(currentActor)) { - throw new Error('Somente a coordenação, a assessoria e o candidato registram custos.') + throw new Error(CAMPAIGN_DEMAND_COST_STAFF_MESSAGE) } await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`]) @@ -123,10 +131,10 @@ export const attachCampaignDemandReceiptRecord = async ( file: File, ) => { if (!RECEIPT_MIME_TYPES.has(file.type)) { - throw new Error('Envie uma imagem (JPEG, PNG, WebP) ou PDF.') + throw new Error(CAMPAIGN_DEMAND_RECEIPT_TYPE_MESSAGE) } - if (file.size === 0) throw new Error('O arquivo enviado está vazio.') - if (file.size > RECEIPT_MAX_BYTES) throw new Error('O comprovante deve ter no máximo 10 MB.') + if (file.size === 0) throw new Error(CAMPAIGN_DEMAND_RECEIPT_EMPTY_MESSAGE) + if (file.size > RECEIPT_MAX_BYTES) throw new Error(CAMPAIGN_DEMAND_RECEIPT_SIZE_MESSAGE) return withPayloadTransaction( payload, @@ -134,7 +142,7 @@ export const attachCampaignDemandReceiptRecord = async ( const currentActor = await reloadStaffActor( payload, actor, - 'Somente a coordenação e a assessoria anexam comprovantes.', + CAMPAIGN_DEMAND_RECEIPT_STAFF_MESSAGE, req, ) await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${demandID}`]) diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index db57b166..d919d05e 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -4,21 +4,27 @@ import { revalidatePath } from 'next/cache' import type { Payload } from 'payload' import { upsertContactByPhone } from '@/app/(campaign)/campanha/actions/supporter' +import { SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys' import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData' import { sanitizeBrazilianPhoneInput } from '@/lib/phone' -import { leaderSupporterCreateSchema } from '@/lib/schemas/supporter' +import { + LEADER_SUPPORTER_ONLY_MESSAGE, + leaderSupporterCreateSchema, + SUPPORTER_DUPLICATE_MESSAGE, +} from '@/lib/schemas/supporter' import type { CampaignUser, Supporter } from '@/payload-types' import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' -import { - requireConsentByKey, - SUPPORTER_REGISTRATION_CONSENT_KEY, -} from '@/utilities/campaignConsent' +import { requireSupporterRegistrationConsent } from '@/utilities/campaignConsent' import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' -import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import { + acquireContactPhoneLocks, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, +} from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { isUniqueSupporterConflict } from '@/utilities/supporterErrors' +import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks' +import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors' export type LeaderSupporterFormState = { status?: 'success' @@ -39,10 +45,10 @@ const MUNICIPALITY_OUT_OF_SCOPE_MESSAGE = 'Você só pode cadastrar contatos nos municípios da sua liderança.' const safeActionMessages = [ - 'Esta pessoa já está cadastrada como apoiador neste município.', - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', - 'Somente lideranças podem cadastrar contatos por aqui.', - 'Consentimento de cadastro de apoiador ainda não configurado.', + SUPPORTER_DUPLICATE_MESSAGE, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, + LEADER_SUPPORTER_ONLY_MESSAGE, + SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE, MUNICIPALITY_OUT_OF_SCOPE_MESSAGE, ] as const @@ -66,7 +72,7 @@ const getFreshLeaderActor = async ( ): Promise => { const currentActor = await reloadCampaignActor(payload, actor, req) if (!isCampaignLeader(currentActor)) { - throw new Error('Somente lideranças podem cadastrar contatos por aqui.') + throw new Error(LEADER_SUPPORTER_ONLY_MESSAGE) } return currentActor } @@ -96,15 +102,10 @@ export const createLeaderSupporterRecord = async ( throw new Error(MUNICIPALITY_OUT_OF_SCOPE_MESSAGE) } - const registrationConsent = await requireConsentByKey( - payload, - SUPPORTER_REGISTRATION_CONSENT_KEY, - req, - 'Consentimento de cadastro de apoiador ainda não configurado.', - ) + const registrationConsent = await requireSupporterRegistrationConsent(payload, req) if (payload.db.name !== 'postgres') { - throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') + throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) } await acquireContactPhoneLocks(payload, req, [data.phone]) @@ -138,7 +139,7 @@ export const createLeaderSupporterRecord = async ( ) } catch (error) { if (isUniqueSupporterConflict(error)) { - throw new Error('Esta pessoa já está cadastrada como apoiador neste município.') + throw new Error(SUPPORTER_DUPLICATE_MESSAGE) } throw error } diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 95cbeedd..eb48a8b8 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -5,8 +5,11 @@ import type { Payload } from 'payload' import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership' import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership' +import { uniqueRelationshipIds } from '@/lib/relationship' import { + LEADERSHIP_DUPLICATE_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + LEADERSHIP_STAFF_MESSAGE, leadershipCreateSchema, leadershipInternalUpdateSchema, leadershipMunicipalitiesMembershipSchema, @@ -18,23 +21,22 @@ import { import type { CampaignUser, Contact } from '@/payload-types' import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' -import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import { + acquireContactPhoneLocks, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, +} from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { uniqueRelationshipIds } from '@/utilities/relationship' +import { + acquireTextAdvisoryLocks, + POSTGRES_DEDUP_LOCK_MESSAGE, +} from '@/utilities/postgresTransactionLocks' const getFreshStaffActor = ( payload: Payload, actor: CampaignUser, req?: PayloadTransactionRequest, -): Promise => - reloadStaffActor( - payload, - actor, - 'Somente a coordenação e a assessoria podem gerenciar lideranças.', - req, - ) +): Promise => reloadStaffActor(payload, actor, LEADERSHIP_STAFF_MESSAGE, req) /** Advisors may only link leaderships to municipalities they administer. */ const assertMunicipalitiesWithinScope = async ( @@ -77,7 +79,7 @@ const createValidatedLeadershipRecord = async ( const currentActor = await getFreshStaffActor(payload, actor, req) await assertMunicipalitiesWithinScope(payload, currentActor, data.municipalities, req) if (payload.db.name !== 'postgres') { - throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') + throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) } await acquireContactPhoneLocks(payload, req, [data.phone]) // Intentional admin bypass: staff scope was freshly checked; these internal reads and @@ -93,9 +95,7 @@ const createValidatedLeadershipRecord = async ( }) if (contacts.totalDocs > 1) { - throw new Error( - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', - ) + throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE) } let contactID = contacts.docs[0]?.id @@ -155,9 +155,7 @@ const createValidatedLeadershipRecord = async ( ) } catch (error) { if (isUniqueLeadershipConflict(error)) { - throw new Error( - 'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novos municípios.', - ) + throw new Error(LEADERSHIP_DUPLICATE_MESSAGE) } throw error diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index 8df5d019..229d1ef8 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -8,6 +8,7 @@ import { getEngagementLevelViolations, } from '@/lib/engagementLevel' import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership' +import { uniqueRelationshipIds } from '@/lib/relationship' import { MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE, MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE, @@ -34,7 +35,6 @@ import { import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { uniqueRelationshipIds } from '@/utilities/relationship' const getFreshStaffActor = ( payload: Payload, diff --git a/src/app/(campaign)/campanha/actions/organization.ts b/src/app/(campaign)/campanha/actions/organization.ts index 058e766c..4fdfabf8 100644 --- a/src/app/(campaign)/campanha/actions/organization.ts +++ b/src/app/(campaign)/campanha/actions/organization.ts @@ -3,6 +3,8 @@ import type { Payload } from 'payload' import { + ORGANIZATION_CONFLICT_MESSAGE, + ORGANIZATION_STAFF_MESSAGE, organizationCreateSchema, organizationUpdateSchema, type OrganizationCreateInput, @@ -14,9 +16,9 @@ import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/camp import { hookFilledCreateData } from '@/utilities/hookFilledData' const organizationPolicy: StaffEntityPolicy = { - staffMessage: 'Somente a coordenação e a assessoria gerenciam organizações.', + staffMessage: ORGANIZATION_STAFF_MESSAGE, conflictPattern: /organization_(name|slug)|duplicate key/i, - conflictMessage: 'Já existe uma organização com este nome.', + conflictMessage: ORGANIZATION_CONFLICT_MESSAGE, } const createOrganizationRecord = async ( diff --git a/src/app/(campaign)/campanha/actions/profile.ts b/src/app/(campaign)/campanha/actions/profile.ts index 59bf3581..5d27a1bb 100644 --- a/src/app/(campaign)/campanha/actions/profile.ts +++ b/src/app/(campaign)/campanha/actions/profile.ts @@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache' import type { Payload } from 'payload' import { getPayload } from 'payload' +import { relationshipId } from '@/lib/relationship' import { getCampaignUser } from '@/utilities/campaignAuth' import { CAMPAIGN_SESSION_EXPIRED_MESSAGE, @@ -17,7 +18,6 @@ import { } from '@/utilities/campaignUserProfile' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { relationshipId } from '@/utilities/relationship' const deleteAvatarMedia = async ( payload: Payload, diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index 0494cc55..e459e36a 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache' import type { Payload } from 'payload' import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership' +import { uniqueRelationshipIds } from '@/lib/relationship' import { STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_STAFF_MESSAGE, @@ -18,10 +19,9 @@ import type { CampaignUser } from '@/payload-types' import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions' import { hookFilledCreateData } from '@/utilities/hookFilledData' -import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { uniqueRelationshipIds } from '@/utilities/relationship' const stateDeputyPolicy: StaffEntityPolicy = { staffMessage: STATE_DEPUTY_STAFF_MESSAGE, diff --git a/src/app/(campaign)/campanha/actions/suggestion.ts b/src/app/(campaign)/campanha/actions/suggestion.ts index 9996c21b..0c562c49 100644 --- a/src/app/(campaign)/campanha/actions/suggestion.ts +++ b/src/app/(campaign)/campanha/actions/suggestion.ts @@ -9,16 +9,16 @@ import { type SuggestionDecisionInput, } from '@/lib/schemas/suggestion' import { - DAY_MS, getSuggestionPattern, isSuggestionSuppressedByDecision, SUGGESTION_TEXT_MAX_LENGTH, suggestionPostponeDays, } from '@/lib/suggestionCatalog' +import { DAY_MS } from '@/lib/text' import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate' import type { AllocationDecision, CampaignUser } from '@/payload-types' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' -import { loadMunicipalitySuggestions } from '@/utilities/municipalityTriggers' +import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 18d594c2..8d085183 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -2,7 +2,11 @@ import type { Payload } from 'payload' +import { requireRelationshipId } from '@/lib/relationship' import { + SUPPORTER_DUPLICATE_MESSAGE, + SUPPORTER_STAFF_MESSAGE, + SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE, supporterCreateSchema, supporterRemoveSchema, supporterVoteIntentionSchema, @@ -14,11 +18,14 @@ import { requireSupporterVoteIntentionConsent, type ConsentDescriptor, } from '@/utilities/campaignConsent' -import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' +import { + acquireContactPhoneLocks, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, +} from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { requireRelationshipId } from '@/utilities/relationship' -import { isUniqueSupporterConflict } from '@/utilities/supporterErrors' +import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks' +import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors' import { confirmSupporterImport as confirmSupporterImportAction, @@ -31,13 +38,7 @@ const getFreshStaffActor = ( payload: Payload, actor: CampaignUser, req?: PayloadTransactionRequest, -): Promise => - reloadStaffActor( - payload, - actor, - 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', - req, - ) +): Promise => reloadStaffActor(payload, actor, SUPPORTER_STAFF_MESSAGE, req) const assertMunicipalityManagement = async ( payload: Payload, @@ -95,9 +96,7 @@ export const upsertContactByPhone = async ({ }) if (contacts.totalDocs > 1) { - throw new Error( - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', - ) + throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE) } const existing = contacts.docs[0] @@ -138,26 +137,18 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i if (data.municipality) { await assertMunicipalityManagement(payload, currentActor, data.municipality, req) } else if (currentActor.role !== 'coordinator') { - throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem município.') + throw new Error(SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE) } - const registrationConsent = await requireSupporterRegistrationConsent( - payload, - req, - 'Consentimento de cadastro de apoiador ainda não configurado.', - ) + const registrationConsent = await requireSupporterRegistrationConsent(payload, req) let voteIntentionConsent: ConsentDescriptor | null = null if (data.voteIntention) { - voteIntentionConsent = await requireSupporterVoteIntentionConsent( - payload, - req, - 'Consentimento de intenção de voto ainda não configurado.', - ) + voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req) } if (payload.db.name !== 'postgres') { - throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') + throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) } await acquireContactPhoneLocks(payload, req, [data.phone]) @@ -200,7 +191,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i ) } catch (error) { if (isUniqueSupporterConflict(error)) { - throw new Error('Esta pessoa já está cadastrada como apoiador neste município.') + throw new Error(SUPPORTER_DUPLICATE_MESSAGE) } throw error } @@ -230,11 +221,7 @@ export const setSupporterVoteIntentionRecord = async ( const currentActor = await getFreshStaffActor(payload, actor, req) await assertCanManageSupporter(payload, currentActor, data.id, req) - const voteIntentionConsent = await requireSupporterVoteIntentionConsent( - payload, - req, - 'Consentimento de intenção de voto ainda não configurado.', - ) + const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req) return payload.update({ collection: 'supporter', @@ -376,6 +363,12 @@ export const removeSupporterDataRecord = async ( const supporter = await assertCanManageSupporter(payload, currentActor, data.id, req) const contactID = requireRelationshipId(supporter.contact) + // Intentional admin bypass (LGPD titular-rights path): `canDeleteSupporter` + // is admin-only by design, but THIS action is the staff-driven data-removal + // flow — the actor's manage scope was already proven by + // `assertCanManageSupporter` above, and the row being deleted is exactly the + // one that scope check loaded. The asymmetry is deliberate: day-to-day + // deletes stay admin-only while the documented removal request path works. await payload.delete({ collection: 'supporter', id: data.id, diff --git a/src/app/(campaign)/campanha/actions/supporterImport.ts b/src/app/(campaign)/campanha/actions/supporterImport.ts index 0dc09787..3112e78a 100644 --- a/src/app/(campaign)/campanha/actions/supporterImport.ts +++ b/src/app/(campaign)/campanha/actions/supporterImport.ts @@ -4,9 +4,15 @@ import { parse } from 'csv-parse/sync' import type { Payload } from 'payload' import { normalizeBrazilianPhone } from '@/lib/phone' +import { relationshipId } from '@/lib/relationship' import { resolveBahiaMunicipality, + SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE, + SUPPORTER_IMPORT_CSV_EMPTY_MESSAGE, + SUPPORTER_IMPORT_CSV_UNREADABLE_MESSAGE, supporterImportConfirmSchema, + supporterImportCsvTooManyRowsMessage, + supporterImportCsvUnknownColumnsMessage, type SupporterVoteIntention, } from '@/lib/schemas/supporter' import type { CampaignUser } from '@/payload-types' @@ -18,22 +24,22 @@ import { import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { relationshipId } from '@/utilities/relationship' +import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks' import { isPreviewErrorRow, isSupporterImportOkRow, type SupporterImportPreviewResult, type SupporterImportPreviewRow, type SupporterImportPreviewRowBase, -} from '@/utilities/supporterImport' -import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk' +} from '@/utilities/supporter/supporterImport' +import { bulkInsertSupporterImport } from '@/utilities/supporter/supporterImportBulk' import { deleteSupporterImportBatch, issueSupporterImportToken, loadSupporterImportBatch, storeSupporterImportBatch, verifySupporterImportToken, -} from '@/utilities/supporterImportToken' +} from '@/utilities/supporter/supporterImportToken' const MAX_IMPORT_ROWS = 5000 @@ -130,23 +136,19 @@ export const previewSupporterImportText = async ( relax_column_count: true, }) as Record[] } catch { - throw new Error('Não foi possível ler o CSV. Verifique o formato e tente novamente.') + throw new Error(SUPPORTER_IMPORT_CSV_UNREADABLE_MESSAGE) } if (records.length === 0) { - throw new Error('O CSV está vazio.') + throw new Error(SUPPORTER_IMPORT_CSV_EMPTY_MESSAGE) } if (records.length > MAX_IMPORT_ROWS) { - throw new Error(`O CSV excede o limite de ${MAX_IMPORT_ROWS} linhas.`) + throw new Error(supporterImportCsvTooManyRowsMessage(MAX_IMPORT_ROWS)) } const unknownColumns = Object.keys(records[0] ?? {}).filter((key) => key.startsWith('__unknown_')) if (unknownColumns.length > 0) { - throw new Error( - `Colunas não reconhecidas no CSV: ${unknownColumns - .map((column) => column.replace(/^__unknown_/, '')) - .join(', ')}. Use apenas nome, telefone, municipio e intencao.`, - ) + throw new Error(supporterImportCsvUnknownColumnsMessage(unknownColumns)) } const phonesInFile = new Set() @@ -312,7 +314,7 @@ export const confirmSupporterImportRecord = async ( const verified = verifySupporterImportToken(data.importToken, actor.id) const batch = await loadSupporterImportBatch(payload, verified.batchId, actor.id) if (batch.okRows.length === 0) { - throw new Error('O lote de importação não contém apoiadores válidos.') + throw new Error(SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE) } return withPayloadTransaction( @@ -323,7 +325,7 @@ export const confirmSupporterImportRecord = async ( const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req) if (payload.db.name !== 'postgres') { - throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') + throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) } const phones = [...new Set(batch.okRows.map((row) => row.telefone))] diff --git a/src/app/(campaign)/campanha/actions/votePledge.ts b/src/app/(campaign)/campanha/actions/votePledge.ts index 0d4f6193..bbaae497 100644 --- a/src/app/(campaign)/campanha/actions/votePledge.ts +++ b/src/app/(campaign)/campanha/actions/votePledge.ts @@ -2,9 +2,14 @@ import type { Payload } from 'payload' +import { relationshipId, requireRelationshipId } from '@/lib/relationship' import { declareVotesSchema, estimateVotesSchema, + VOTE_PLEDGE_DECLARE_STAFF_MESSAGE, + VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE, + VOTE_PLEDGE_LEADERSHIP_REQUIRED_MESSAGE, + VOTE_PLEDGE_MUNICIPALITY_NOT_LINKED_MESSAGE, type DeclareVotesInput, type EstimateVotesInput, } from '@/lib/schemas/votePledge' @@ -14,11 +19,6 @@ import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { relationshipId, requireRelationshipId } from '@/utilities/relationship' - -const STAFF_DECLARE_REQUIRED = 'Somente a coordenação e a assessoria registram votos declarados.' -const MUNICIPALITY_NOT_LINKED = - 'A liderança precisa estar vinculada ao município para registrar votos declarados nele.' /** * Declare (or update) the votes a leadership is bringing in one municipality. @@ -37,10 +37,10 @@ export const declareVotesRecord = async ( const currentActor = await reloadCampaignActor(payload, actor, req) if (!isCampaignStaff(currentActor)) { - throw new Error(STAFF_DECLARE_REQUIRED) + throw new Error(VOTE_PLEDGE_DECLARE_STAFF_MESSAGE) } - if (!data.leadership) throw new Error('Informe a liderança da declaração.') + if (!data.leadership) throw new Error(VOTE_PLEDGE_LEADERSHIP_REQUIRED_MESSAGE) const leadershipID = data.leadership const leadership = await payload.findByID({ @@ -56,7 +56,7 @@ export const declareVotesRecord = async ( .map(relationshipId) .filter((id): id is number => id !== null) if (!linkedMunicipalityIDs.includes(data.municipality)) { - throw new Error(MUNICIPALITY_NOT_LINKED) + throw new Error(VOTE_PLEDGE_MUNICIPALITY_NOT_LINKED_MESSAGE) } await payload.findByID({ @@ -140,7 +140,7 @@ export const estimateVotesRecord = async ( async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) if (!isCampaignStaff(currentActor)) { - throw new Error('Somente a coordenação, a assessoria e o candidato registram estimativas.') + throw new Error(VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE) } // Row access scopes the pledge to the actor (advisor: administered municipalities). diff --git a/src/app/(campaign)/campanha/actions/webauthn.ts b/src/app/(campaign)/campanha/actions/webauthn.ts index 21bacd07..918ead79 100644 --- a/src/app/(campaign)/campanha/actions/webauthn.ts +++ b/src/app/(campaign)/campanha/actions/webauthn.ts @@ -14,7 +14,7 @@ import { runCampaignFormAction } from '@/utilities/campaignFormActionError' import { CAMPAIGN_PASSKEY_REMOVE_DENIED_MESSAGE, removeCampaignPasskey, -} from '@/utilities/campaignWebAuthnCeremony' +} from '@/utilities/webauthn/campaignWebAuthnCeremony' /** * Revokes one enrolled device. The delete runs with `overrideAccess: false`, so diff --git a/src/app/(campaign)/campanha/login/page.tsx b/src/app/(campaign)/campanha/login/page.tsx index 091094ce..527e5628 100644 --- a/src/app/(campaign)/campanha/login/page.tsx +++ b/src/app/(campaign)/campanha/login/page.tsx @@ -3,7 +3,7 @@ import { redirect } from 'next/navigation' import { CampaignAuthPageShell } from '@/components/campaign/auth/CampaignAuthPageShell' import { getCampaignUser } from '@/utilities/campaignAuth' -import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/campaignWebAuthnConfig' +import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig' import { LoginForm } from './LoginForm' export const metadata: Metadata = { diff --git a/src/app/(campaign)/campanha/webauthn/login-options/route.ts b/src/app/(campaign)/campanha/webauthn/login-options/route.ts index a19aa089..c6e6e3a2 100644 --- a/src/app/(campaign)/campanha/webauthn/login-options/route.ts +++ b/src/app/(campaign)/campanha/webauthn/login-options/route.ts @@ -7,7 +7,7 @@ import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute import { buildCampaignAuthenticationOptions, campaignWebAuthnSafeMessages, -} from '@/utilities/campaignWebAuthnCeremony' +} from '@/utilities/webauthn/campaignWebAuthnCeremony' export const dynamic = 'force-dynamic' diff --git a/src/app/(campaign)/campanha/webauthn/login/route.ts b/src/app/(campaign)/campanha/webauthn/login/route.ts index ac9124df..468cf3ca 100644 --- a/src/app/(campaign)/campanha/webauthn/login/route.ts +++ b/src/app/(campaign)/campanha/webauthn/login/route.ts @@ -10,12 +10,12 @@ import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute import { campaignWebAuthnSafeMessages, completeCampaignAuthentication, -} from '@/utilities/campaignWebAuthnCeremony' -import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/campaignWebAuthnChallenge' +} from '@/utilities/webauthn/campaignWebAuthnCeremony' +import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/webauthn/campaignWebAuthnChallenge' import { CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE, CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE, -} from '@/utilities/campaignWebAuthnSession' +} from '@/utilities/webauthn/campaignWebAuthnSession' export const dynamic = 'force-dynamic' diff --git a/src/app/(campaign)/campanha/webauthn/register-options/route.ts b/src/app/(campaign)/campanha/webauthn/register-options/route.ts index d999e213..f637dc79 100644 --- a/src/app/(campaign)/campanha/webauthn/register-options/route.ts +++ b/src/app/(campaign)/campanha/webauthn/register-options/route.ts @@ -8,7 +8,7 @@ import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute import { buildCampaignRegistrationOptions, campaignWebAuthnSafeMessages, -} from '@/utilities/campaignWebAuthnCeremony' +} from '@/utilities/webauthn/campaignWebAuthnCeremony' export const dynamic = 'force-dynamic' diff --git a/src/app/(campaign)/campanha/webauthn/register/route.ts b/src/app/(campaign)/campanha/webauthn/register/route.ts index 0ee6fc54..28f4dde3 100644 --- a/src/app/(campaign)/campanha/webauthn/register/route.ts +++ b/src/app/(campaign)/campanha/webauthn/register/route.ts @@ -11,8 +11,8 @@ import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute import { campaignWebAuthnSafeMessages, completeCampaignRegistration, -} from '@/utilities/campaignWebAuthnCeremony' -import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/campaignWebAuthnChallenge' +} from '@/utilities/webauthn/campaignWebAuthnCeremony' +import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/webauthn/campaignWebAuthnChallenge' export const dynamic = 'force-dynamic' diff --git a/src/app/(frontend)/actions/submitPetitionSignature.ts b/src/app/(frontend)/actions/submitPetitionSignature.ts index b4a01f66..f4205879 100644 --- a/src/app/(frontend)/actions/submitPetitionSignature.ts +++ b/src/app/(frontend)/actions/submitPetitionSignature.ts @@ -6,18 +6,30 @@ import { getPayload } from 'payload' type SubmitPetitionSignatureInput = PetitionFormInput & { petitionId: string - consentId: number } export const submitPetitionSignature = async ({ petitionId, - consentId, comment, ...contactInput }: SubmitPetitionSignatureInput) => { const data = petitionFormSchema.parse(contactInput) const payload = await getPayload({ config }) + // The consent document is resolved server-side from the petition itself — + // never trusted from the client (P3-B, same policy as Pass 2 D3): the page + // renders the text from `petition.form.consent`, and the signature must + // record exactly that document. Petition read access is public, like the + // page that renders it; a `consentId` posted by the client is dropped at + // the input boundary above, so a tampered one is inert. + const petition = await payload.findByID({ + collection: 'petition', + id: petitionId, + depth: 0, + }) + const consentId = + typeof petition.form.consent === 'number' ? petition.form.consent : petition.form.consent.id + const transactionID = await payload.db.beginTransaction() if (!transactionID) { diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 50f687ff..3360f937 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -5,6 +5,7 @@ import type { } from 'payload' import { APIError } from 'payload' +import { relationshipId } from '@/lib/relationship' import { activityKindLabels, activityKinds, @@ -14,6 +15,7 @@ import { activityStatuses, } from '@/lib/schemas/activity' import { slugify } from '@/lib/slug' +import { trimmedText } from '@/lib/text' import { canCreateActivity, canCreateActivityAdvisors, @@ -27,9 +29,7 @@ import { canUpdateActivity, eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' -import { relationshipId } from '@/utilities/relationship' - -const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +import { systemStampedActorField } from '@/utilities/campaignAuditFields' const isActivityMutationShortcut = (context: Record | undefined) => context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate' @@ -612,19 +612,6 @@ export const Activity: CollectionConfig = { update: canSetActivitySystemField, }, }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criada por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetActivitySystemField, - update: canSetActivitySystemField, - }, - }, + systemStampedActorField({ label: 'Criada por', setAccess: canSetActivitySystemField }), ], } diff --git a/src/collections/AllocationDecision.ts b/src/collections/AllocationDecision.ts index 8f2ee6b5..ea83bbf0 100644 --- a/src/collections/AllocationDecision.ts +++ b/src/collections/AllocationDecision.ts @@ -1,3 +1,4 @@ +import { systemStampedActorField } from '@/utilities/campaignAuditFields' import { Buffer } from 'node:buffer' import type { @@ -136,19 +137,10 @@ export const AllocationDecision: CollectionConfig = { description: 'Somente os números e classificações usados no momento da decisão.', }, }, - { + systemStampedActorField({ name: 'decidedBy', - type: 'relationship', - relationTo: 'campaignUser', label: 'Decidido por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetCampaignSystemField, - update: canSetCampaignSystemField, - }, - }, + setAccess: canSetCampaignSystemField, + }), ], } diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts index 5fcaea30..44883888 100644 --- a/src/collections/CampaignDemand.ts +++ b/src/collections/CampaignDemand.ts @@ -5,7 +5,10 @@ import type { } from 'payload' import { APIError } from 'payload' +import { relationshipId } from '@/lib/relationship' import { + CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE, + CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE, campaignDemandKindLabels, campaignDemandKinds, campaignDemandStatusLabels, @@ -14,6 +17,7 @@ import { type CampaignDemandStatus, } from '@/lib/schemas/campaignDemand' import { slugify } from '@/lib/slug' +import { trimmedText } from '@/lib/text' import { canCreateCampaignDemand, canDeleteCampaignDemand, @@ -24,9 +28,7 @@ import { canUpdateCampaignDemand, isCampaignUnrestricted, } from '@/utilities/campaignAccess' -import { relationshipId } from '@/utilities/relationship' - -const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +import { systemStampedActorField } from '@/utilities/campaignAuditFields' const DEMAND_KIND_OPTIONS = campaignDemandKinds.map((value) => ({ value, @@ -90,7 +92,7 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({ const rawNextStatus: unknown = data.status === undefined ? previousStatus : data.status if (!isDemandStatus(rawNextStatus)) { - throw new APIError('Status de demanda inválido.', 400) + throw new APIError(CAMPAIGN_DEMAND_INVALID_STATUS_MESSAGE, 400) } const nextStatus: CampaignDemandStatus = rawNextStatus @@ -109,10 +111,7 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({ actor && !isCampaignUnrestricted(actor) ) { - throw new APIError( - 'Demandas escaladas são decididas pelo Coordenador Geral ou Candidato.', - 403, - ) + throw new APIError(CAMPAIGN_DEMAND_ESCALATED_DECISION_MESSAGE, 403) } const history = Array.isArray(originalDoc?.statusHistory) ? [...originalDoc.statusHistory] : [] @@ -378,19 +377,6 @@ export const CampaignDemand: CollectionConfig = { }, ], }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetCampaignSystemField, - update: canSetCampaignSystemField, - }, - }, + systemStampedActorField({ setAccess: canSetCampaignSystemField }), ], } diff --git a/src/collections/CampaignInvite.ts b/src/collections/CampaignInvite.ts index 6db28466..d83014a5 100644 --- a/src/collections/CampaignInvite.ts +++ b/src/collections/CampaignInvite.ts @@ -6,6 +6,7 @@ import { canReadCampaignInvite, canSetCampaignInviteSystemField, } from '@/utilities/campaignAccess' +import { systemStampedActorField } from '@/utilities/campaignAuditFields' import { campaignInviteExpiry } from '@/utilities/campaignInvite' export const CampaignInvite: CollectionConfig = { @@ -103,20 +104,6 @@ export const CampaignInvite: CollectionConfig = { update: canSetCampaignInviteSystemField, }, }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - required: true, - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetCampaignInviteSystemField, - update: canSetCampaignInviteSystemField, - }, - }, + systemStampedActorField({ required: true, setAccess: canSetCampaignInviteSystemField }), ], } diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 40b3ece4..387f5c06 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -1,5 +1,5 @@ import { CitiesByState } from '@/lib/cities' -import { normalizeBrazilianPhone } from '@/lib/phone' +import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone' import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess' import { acquireContactPhoneLocks, @@ -77,14 +77,14 @@ export const Contact: CollectionConfig = { const input = String(data.phone) if (/^\d{11}$/.test(input)) { if (!/^[1-9]{2}9\d{8}$/.test(input)) { - throw new APIError('Celular brasileiro inválido.', 400) + throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400) } return data } const phone = normalizeBrazilianPhone(input) if (!phone) { - throw new APIError('Celular brasileiro inválido.', 400) + throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400) } data.phone = phone diff --git a/src/collections/Leadership.ts b/src/collections/Leadership.ts index 5647fb71..45aefa8c 100644 --- a/src/collections/Leadership.ts +++ b/src/collections/Leadership.ts @@ -1,6 +1,7 @@ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' import { APIError } from 'payload' +import { relationshipId } from '@/lib/relationship' import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership' import { canCreateLeadership, @@ -11,7 +12,7 @@ import { canReadLeadership, canSetAdministrativeLeadershipField, } from '@/utilities/campaignAccess' -import { relationshipId } from '@/utilities/relationship' +import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields' const requireAtLeastOneMunicipality: CollectionBeforeValidateHook = ({ data, @@ -59,15 +60,7 @@ export const Leadership: CollectionConfig = { }, hooks: { beforeValidate: [requireAtLeastOneMunicipality], - beforeChange: [ - ({ data, operation, req }) => { - if (operation === 'create' && req.user?.collection === 'campaignUser') { - data.createdBy = req.user.id - } - - return data - }, - ], + beforeChange: [stampCampaignCreatedBy], }, fields: [ { @@ -218,20 +211,9 @@ export const Leadership: CollectionConfig = { update: canManageCampaignStaffField, }, }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetAdministrativeLeadershipField, - read: canReadCampaignStaffField, - update: canSetAdministrativeLeadershipField, - }, - }, + systemStampedActorField({ + readAccess: canReadCampaignStaffField, + setAccess: canSetAdministrativeLeadershipField, + }), ], } diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index 1efae5ce..618a5edb 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -10,6 +10,7 @@ import { engagementLevels, formatEngagementLevelLabel, } from '@/lib/engagementLevel' +import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship' import { politicalTrendStatuses } from '@/lib/schemas/municipality' import { getVoteEstimateOrderViolation, @@ -29,8 +30,7 @@ import { canUpdateMunicipality, eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' -import { politicalTrendLabels } from '@/utilities/municipalityLabels' -import { relationshipId, uniqueRelationshipIds } from '@/utilities/relationship' +import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels' import { voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupFields, diff --git a/src/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index 0d885abc..16564a55 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -9,6 +9,7 @@ import type { } from 'payload' import { APIError } from 'payload' +import { relationshipId } from '@/lib/relationship' import { municipalitySignalTypeLabels, municipalitySignalTypes, @@ -24,7 +25,6 @@ import { canSetMunicipalityUpdateAuthor, } from '@/utilities/campaignAccess' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { relationshipId } from '@/utilities/relationship' const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField' const MUNICIPALITY_UPDATE_KIND_OPTIONS = municipalityUpdateKinds.map((value) => ({ diff --git a/src/collections/Organization.ts b/src/collections/Organization.ts index 3f34c987..94ac1e07 100644 --- a/src/collections/Organization.ts +++ b/src/collections/Organization.ts @@ -2,6 +2,7 @@ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' import { APIError } from 'payload' import { slugify } from '@/lib/slug' +import { trimmedText } from '@/lib/text' import { canCreateOrganization, canDeleteOrganization, @@ -9,8 +10,7 @@ import { canReadOrganization, canSetCampaignSystemField, } from '@/utilities/campaignAccess' - -const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields' const setCanonicalOrganizationSlug: CollectionBeforeValidateHook = ({ data, @@ -52,15 +52,7 @@ export const Organization: CollectionConfig = { }, hooks: { beforeValidate: [setCanonicalOrganizationSlug], - beforeChange: [ - ({ data, operation, req }) => { - if (operation === 'create' && req.user?.collection === 'campaignUser') { - data.createdBy = req.user.id - } - - return data - }, - ], + beforeChange: [stampCampaignCreatedBy], }, fields: [ { @@ -117,19 +109,6 @@ export const Organization: CollectionConfig = { label: 'Observações', maxLength: 4000, }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetCampaignSystemField, - update: canSetCampaignSystemField, - }, - }, + systemStampedActorField({ setAccess: canSetCampaignSystemField }), ], } diff --git a/src/collections/StateDeputy.ts b/src/collections/StateDeputy.ts index 25054de0..97adc10f 100644 --- a/src/collections/StateDeputy.ts +++ b/src/collections/StateDeputy.ts @@ -2,6 +2,7 @@ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' import { APIError } from 'payload' import { slugify } from '@/lib/slug' +import { trimmedText } from '@/lib/text' import { canCreateStateDeputy, canDeleteStateDeputy, @@ -9,8 +10,7 @@ import { canReadStateDeputy, canSetCampaignSystemField, } from '@/utilities/campaignAccess' - -const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields' const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = ({ data, @@ -52,15 +52,7 @@ export const StateDeputy: CollectionConfig = { }, hooks: { beforeValidate: [setCanonicalStateDeputySlug], - beforeChange: [ - ({ data, operation, req }) => { - if (operation === 'create' && req.user?.collection === 'campaignUser') { - data.createdBy = req.user.id - } - - return data - }, - ], + beforeChange: [stampCampaignCreatedBy], }, fields: [ { @@ -101,19 +93,6 @@ export const StateDeputy: CollectionConfig = { label: 'Observações', maxLength: 4000, }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetCampaignSystemField, - update: canSetCampaignSystemField, - }, - }, + systemStampedActorField({ setAccess: canSetCampaignSystemField }), ], } diff --git a/src/collections/Supporter.ts b/src/collections/Supporter.ts index a1d1eaae..91dea2c2 100644 --- a/src/collections/Supporter.ts +++ b/src/collections/Supporter.ts @@ -1,6 +1,7 @@ import type { CollectionConfig } from 'payload' import { APIError } from 'payload' +import { relationshipId } from '@/lib/relationship' import { canCreateSupporter, canDeleteSupporter, @@ -10,7 +11,7 @@ import { canReadSupporter, canSetAdministrativeLeadershipField, } from '@/utilities/campaignAccess' -import { relationshipId } from '@/utilities/relationship' +import { systemStampedActorField } from '@/utilities/campaignAuditFields' export const Supporter: CollectionConfig = { slug: 'supporter', @@ -210,20 +211,9 @@ export const Supporter: CollectionConfig = { update: canManageCampaignStaffField, }, }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetAdministrativeLeadershipField, - read: canReadCampaignStaffField, - update: canSetAdministrativeLeadershipField, - }, - }, + systemStampedActorField({ + readAccess: canReadCampaignStaffField, + setAccess: canSetAdministrativeLeadershipField, + }), ], } diff --git a/src/collections/VotePledge.ts b/src/collections/VotePledge.ts index c2226dd6..6f1bf87a 100644 --- a/src/collections/VotePledge.ts +++ b/src/collections/VotePledge.ts @@ -1,6 +1,7 @@ import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload' import { APIError } from 'payload' +import { relationshipId } from '@/lib/relationship' import { type VoteEstimateScenarioFields, getVoteEstimateOrderViolation, @@ -16,7 +17,7 @@ import { canUpdateVotePledge, payloadAdminOnly, } from '@/utilities/campaignAccess' -import { relationshipId } from '@/utilities/relationship' +import { systemStampedActorField } from '@/utilities/campaignAuditFields' import { voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupFields, @@ -207,19 +208,12 @@ export const VotePledge: CollectionConfig = { update: canSetCampaignSystemField, }, }, - { + systemStampedActorField({ name: 'declaredBy', - type: 'relationship', - relationTo: 'campaignUser', label: 'Declarado por', - admin: { - readOnly: true, - }, - access: { - create: canSetCampaignSystemField, - update: canSetCampaignSystemField, - }, - }, + indexed: false, + setAccess: canSetCampaignSystemField, + }), { name: 'estimatedVotes', type: 'group', diff --git a/src/components/PetitionForm.tsx b/src/components/PetitionForm.tsx index 7e6dda27..0eac50ff 100644 --- a/src/components/PetitionForm.tsx +++ b/src/components/PetitionForm.tsx @@ -52,15 +52,11 @@ export const PetitionForm = ({ id, petition, consentHTML, facebookPixelId }: Pet const [signatureNumber, setSignatureNumber] = useState(null) const onSubmit: SubmitHandler = (input) => { - const consentId = - typeof petition.form?.consent === 'number' ? petition.form.consent : petition.form.consent.id - startTransition(async () => { try { const result = await submitPetitionSignature({ ...input, petitionId: petition.id, - consentId, }) if (facebookPixelId) { trackMetaLead(facebookPixelId, petition.title, crypto.randomUUID()) diff --git a/src/components/campaign/activity/ActivityCard.tsx b/src/components/campaign/activity/ActivityCard.tsx index 20bed275..39dbd5e7 100644 --- a/src/components/campaign/activity/ActivityCard.tsx +++ b/src/components/campaign/activity/ActivityCard.tsx @@ -4,9 +4,9 @@ import Link from 'next/link' import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge' import { Badge } from '@/components/ui/Badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { activityKindLabels } from '@/lib/schemas/activity' import type { ActivityListViewModel } from '@/utilities/activityViewModels' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' const formatWhen = (activity: ActivityListViewModel): string => activity.startAt ? formatBahiaDateTimeLabel(activity.startAt) : 'Data a definir' diff --git a/src/components/campaign/activity/ActivityFilters.tsx b/src/components/campaign/activity/ActivityFilters.tsx index b23c7e3b..e9162146 100644 --- a/src/components/campaign/activity/ActivityFilters.tsx +++ b/src/components/campaign/activity/ActivityFilters.tsx @@ -1,12 +1,10 @@ 'use client' -import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending' -import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react' import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' -import { useRef, useState } from 'react' +import { CampaignCollapsibleFilterPanel } from '@/components/campaign/shared/CampaignCollapsibleFilterPanel' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' +import { useCampaignFilterValues } from '@/components/campaign/shared/useCampaignFilterValues' import { Button } from '@/components/ui/button' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' @@ -76,36 +74,20 @@ export const ActivityFilters = ({ state: ActivityListState municipalityOptions: RelationOption[] }) => { - const router = useRouter() - const pathname = usePathname() - const initialValues = valuesFromState(state) - const valuesRef = useRef(initialValues) - const [values, setValues] = useState(initialValues) - const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false) - const { isPending, startTransition } = useCampaignListTransition() - - const replaceValues = (nextValues: FilterValues) => { - valuesRef.current = nextValues - setValues(nextValues) - const params = buildActivityListSearchParams({ - page: 1, - tab: state.tab, - kind: nextValues.kind as ActivityListState['kind'], - status: nextValues.status as ActivityListState['status'], - municipality: municipalityIdFromValue(nextValues.municipality), - }) - const query = params.toString() - startTransition(() => { - router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }) - }) - } - - const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind }) - const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status }) - const updateMunicipality = (municipality: string) => - replaceValues({ ...valuesRef.current, municipality }) - - const clearFilters = () => replaceValues({ kind: '', status: '', municipality: '' }) + const { values, isPending, replaceValues, updateValues } = useCampaignFilterValues({ + committedValues: valuesFromState(state), + toHref: (next) => { + const params = buildActivityListSearchParams({ + page: 1, + tab: state.tab, + kind: next.kind as ActivityListState['kind'], + status: next.status as ActivityListState['status'], + municipality: municipalityIdFromValue(next.municipality), + }) + const query = params.toString() + return query ? `/campanha/atividades?${query}` : '/campanha/atividades' + }, + }) const hasFilters = Object.values(values).some(Boolean) @@ -121,98 +103,75 @@ export const ActivityFilters = ({

{isPending ? 'Atualizando resultados…' : ''}

-
- + replaceValues({ kind: '', status: '', municipality: '' })} + >
-
+ + Tipo de atividade + + updateValues((current) => ({ ...current, kind: event.target.value })) + } + className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" + > + Todos os tipos + {Object.entries(activityKindLabels).map(([value, label]) => ( + + {label} + + ))} + + + {state.tab === 'todos' ? ( - Tipo de atividade + Status updateKind(event.target.value)} + id="activity-status" + value={values.status} + onChange={(event) => + updateValues((current) => ({ ...current, status: event.target.value })) + } className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" > - Todos os tipos - {Object.entries(activityKindLabels).map(([value, label]) => ( + Todos os status + {Object.entries(activityStatusLabels).map(([value, label]) => ( {label} ))} - {state.tab === 'todos' ? ( - - Status - updateStatus(event.target.value)} - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todos os status - {Object.entries(activityStatusLabels).map(([value, label]) => ( - - {label} - - ))} - - - ) : null} - - Município - updateMunicipality(event.target.value)} - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todos os municípios - {municipalityOptions.map((option) => ( - - {option.name} - - ))} - - -
- {hasFilters ? ( -
- -
) : null} + + Município + + updateValues((current) => ({ ...current, municipality: event.target.value })) + } + className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" + > + Todos os municípios + {municipalityOptions.map((option) => ( + + {option.name} + + ))} + +
-
+ ) diff --git a/src/components/campaign/activity/ActivityForm.tsx b/src/components/campaign/activity/ActivityForm.tsx index 0fc24322..204875a6 100644 --- a/src/components/campaign/activity/ActivityForm.tsx +++ b/src/components/campaign/activity/ActivityForm.tsx @@ -32,6 +32,7 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' +import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime' import { isContactSearchQueryReady } from '@/lib/contactSearchQuery' import { activityKindLabels, @@ -41,8 +42,7 @@ import { import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions' import type { ActivityFormViewModel } from '@/utilities/activityViewModels' import { fieldError } from '@/utilities/campaignFormFields' -import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime' -import type { EligibleAdvisorOption } from '@/utilities/municipalityViewModels' +import type { EligibleAdvisorOption } from '@/utilities/municipality/municipalityViewModels' export type ActivityFormState = { message?: string diff --git a/src/components/campaign/activity/ActivityResultForm.tsx b/src/components/campaign/activity/ActivityResultForm.tsx index 1112f2f4..26fdb0a4 100644 --- a/src/components/campaign/activity/ActivityResultForm.tsx +++ b/src/components/campaign/activity/ActivityResultForm.tsx @@ -1,18 +1,18 @@ 'use client' import { useRouter } from 'next/navigation' -import { useActionState, useEffect } from 'react' -import { toast } from 'sonner' +import { useActionState } from 'react' import { registerActivityResultFormAction } from '@/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' +import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { fieldError } from '@/utilities/campaignFormFields' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' type ActivityResultFormProps = { activityId: number @@ -30,11 +30,7 @@ export const ActivityResultForm = ({ const router = useRouter() const [state, formAction, pending] = useActionState(registerActivityResultFormAction, {}) - useEffect(() => { - if (state.status !== 'success') return - toast.success(state.message) - router.refresh() - }, [router, state.message, state.status]) + useCampaignFormSuccessToast(state, router.refresh) const summaryError = fieldError(state.fieldErrors, 'resultSummary') const recordedLabel = initialSummary @@ -55,11 +51,8 @@ export const ActivityResultForm = ({
- {state.message && state.status !== 'success' ? ( - - Não foi possível registrar - {state.message} - + {state.status !== 'success' ? ( + ) : null} O que aconteceu * diff --git a/src/components/campaign/activity/ActivityTaskChecklist.tsx b/src/components/campaign/activity/ActivityTaskChecklist.tsx index 5ef6a34d..30cffffc 100644 --- a/src/components/campaign/activity/ActivityTaskChecklist.tsx +++ b/src/components/campaign/activity/ActivityTaskChecklist.tsx @@ -5,8 +5,8 @@ import { toast } from 'sonner' import { toggleActivityTaskAction } from '@/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions' import { Checkbox } from '@/components/ui/Checkbox' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import type { ActivityTaskViewModel } from '@/utilities/activityViewModels' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' type ActivityTaskChecklistProps = { activityId: number diff --git a/src/components/campaign/activity/ActivityTaskFields.tsx b/src/components/campaign/activity/ActivityTaskFields.tsx index f2051538..5d8c2895 100644 --- a/src/components/campaign/activity/ActivityTaskFields.tsx +++ b/src/components/campaign/activity/ActivityTaskFields.tsx @@ -10,7 +10,7 @@ import { import { Button } from '@/components/ui/button' import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' -import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime' +import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime' export type ActivityTaskFieldValue = { key: string diff --git a/src/components/campaign/activity/ActivityUpdateFeed.tsx b/src/components/campaign/activity/ActivityUpdateFeed.tsx index b7fae5bb..9416b8ad 100644 --- a/src/components/campaign/activity/ActivityUpdateFeed.tsx +++ b/src/components/campaign/activity/ActivityUpdateFeed.tsx @@ -2,8 +2,8 @@ import { MessageSquareTextIcon } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import type { ActivityUpdateViewModel } from '@/utilities/activityViewModels' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' const UpdateCard = ({ update }: { update: ActivityUpdateViewModel }) => (
diff --git a/src/components/campaign/activity/ActivityUpdateForm.tsx b/src/components/campaign/activity/ActivityUpdateForm.tsx index a273bd43..d98eaa51 100644 --- a/src/components/campaign/activity/ActivityUpdateForm.tsx +++ b/src/components/campaign/activity/ActivityUpdateForm.tsx @@ -1,11 +1,11 @@ 'use client' import { useRouter } from 'next/navigation' -import { useActionState, useEffect, useRef } from 'react' -import { toast } from 'sonner' +import { useActionState, useRef } from 'react' import { createActivityUpdateFormAction } from '@/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' +import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' +import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Field, FieldError, FieldLabel } from '@/components/ui/field' @@ -18,12 +18,10 @@ export const ActivityUpdateForm = ({ activityId }: { activityId: number }) => { const formRef = useRef(null) const [state, formAction, pending] = useActionState(createActivityUpdateFormAction, {}) - useEffect(() => { - if (state.status !== 'success') return - toast.success(state.message) + useCampaignFormSuccessToast(state, () => { formRef.current?.reset() router.refresh() - }, [router, state.message, state.status]) + }) const bodyError = fieldError(state.fieldErrors, 'body') @@ -35,11 +33,8 @@ export const ActivityUpdateForm = ({ activityId }: { activityId: number }) => { - {state.message && state.status !== 'success' ? ( - - Não foi possível enviar - {state.message} - + {state.status !== 'success' ? ( + ) : null} Texto da atualização * diff --git a/src/components/campaign/advisor/AdvisorDebouncedTextCell.tsx b/src/components/campaign/advisor/AdvisorDebouncedTextCell.tsx index c64328fe..4e1328d1 100644 --- a/src/components/campaign/advisor/AdvisorDebouncedTextCell.tsx +++ b/src/components/campaign/advisor/AdvisorDebouncedTextCell.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' +import { campaignInlineInputClassName } from '@/lib/campaignInlineInput' import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' import { isPlanilhaPlaceholderEmail, @@ -118,7 +119,7 @@ export const AdvisorDebouncedTextCell = ({ placeholder={placeholder} aria-label={ariaLabel} aria-busy={isPending} - className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input" + className={campaignInlineInputClassName} onChange={(event: ChangeEvent) => { const next = type === 'tel' diff --git a/src/components/campaign/advisor/AdvisorPasswordResetButton.tsx b/src/components/campaign/advisor/AdvisorPasswordResetButton.tsx index 89e2353d..3e0a8a5b 100644 --- a/src/components/campaign/advisor/AdvisorPasswordResetButton.tsx +++ b/src/components/campaign/advisor/AdvisorPasswordResetButton.tsx @@ -4,6 +4,7 @@ import { KeyRoundIcon } from 'lucide-react' import { useActionState, useEffect } from 'react' import { toast } from 'sonner' +import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/Spinner' import { PLACEHOLDER_RESET_MESSAGE } from '@/lib/schemas/advisor' @@ -31,11 +32,9 @@ export const AdvisorPasswordResetButton = ({ }: AdvisorPasswordResetButtonProps) => { const [state, submitAction, isPending] = useActionState(formAction, {}) + useCampaignFormSuccessToast({ status: state.status, message: state.message ?? 'Link enviado.' }) + useEffect(() => { - if (state.status === 'success') { - toast.success(state.message ?? 'Link enviado.') - return - } if (state.message && state.status !== 'success') { toast.error(state.message) } diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx index fc3a4b52..ce51c6ff 100644 --- a/src/components/campaign/advisor/AdvisorsTable.tsx +++ b/src/components/campaign/advisor/AdvisorsTable.tsx @@ -33,6 +33,7 @@ import { TableHeader, TableRow, } from '@/components/ui/Table' +import { campaignInlineInputClassName } from '@/lib/campaignInlineInput' import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { formatBrazilianPhoneInput, @@ -220,7 +221,7 @@ export const AdvisorsTable = ({ }} placeholder="Nome" aria-label="Nome do novo assessor" - className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input" + className={campaignInlineInputClassName} autoFocus /> @@ -234,7 +235,7 @@ export const AdvisorsTable = ({ }} placeholder="E-mail" aria-label="E-mail do novo assessor" - className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input" + className={campaignInlineInputClassName} /> @@ -250,7 +251,7 @@ export const AdvisorsTable = ({ }} placeholder="Celular" aria-label="Celular do novo assessor" - className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input" + className={campaignInlineInputClassName} /> diff --git a/src/components/campaign/dashboard/CampaignDashboard.tsx b/src/components/campaign/dashboard/CampaignDashboard.tsx index b1958d1b..ba45b3ba 100644 --- a/src/components/campaign/dashboard/CampaignDashboard.tsx +++ b/src/components/campaign/dashboard/CampaignDashboard.tsx @@ -21,9 +21,9 @@ import { formatGoalCoverageDeficitLabel, formatGoalCoverageRatioLabel, goalCoverageProgressPercent, -} from '@/utilities/goalCoverage' -import { buildMunicipalityListHref } from '@/utilities/municipalityListUrl' -import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' +} from '@/utilities/municipality/goalCoverage' +import { buildMunicipalityListHref } from '@/utilities/municipality/municipalityListUrl' +import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipality/municipalityMapNavigation' const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', diff --git a/src/components/campaign/dashboard/CampaignDataFreshness.tsx b/src/components/campaign/dashboard/CampaignDataFreshness.tsx index 805015d0..899554b2 100644 --- a/src/components/campaign/dashboard/CampaignDataFreshness.tsx +++ b/src/components/campaign/dashboard/CampaignDataFreshness.tsx @@ -1,5 +1,5 @@ +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { cn } from '@/lib/utils' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' /** Request-time aggregate stamp — absolute Bahia time on hover. */ export const CampaignDataFreshness = ({ asOf, className }: { asOf: Date; className?: string }) => { diff --git a/src/components/campaign/demand/DemandForm.tsx b/src/components/campaign/demand/DemandForm.tsx index 2002511d..4fa508fd 100644 --- a/src/components/campaign/demand/DemandForm.tsx +++ b/src/components/campaign/demand/DemandForm.tsx @@ -2,8 +2,8 @@ import { useActionState, useState } from 'react' +import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' -import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' @@ -149,11 +149,7 @@ export const DemandForm = ({ ) : null} - {state.message && state.status !== 'success' ? ( - - {state.message} - - ) : null} + {state.status !== 'success' ? : null}