diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index d2185b41..a4d9aad9 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -3,10 +3,11 @@ description: Estado do projeto Núcleos Eleitorais alwaysApply: false --- -# Núcleos Eleitorais +# Núcleos Eleitorais → Praças ## Status +- **REMODELAGEM ENTREGUE EM CÓDIGO (2026-07-21; deploy pendente — migração `20260721_020109_remodel_plazas` é destrutiva para a vertical campanha, revisar SQL antes do build de produção). Suítes verdes: unit 187, int 306 (incl. assimetria declarado×estimado, access por papel, workflow de demandas), e2e 9. Critique/polish visual fino registrado como R6 no roadmap.** Feedback da coordenação invalidou o modelo de núcleo abstrato. Decisões travadas: 436 Praças pré-definidas (415 municípios + 19 zonas de Salvador + 2 de Camaçari; zonas compartilhadas cortadas na divisa municipal); código `plaza`; roles renomeados para `coordinator`/`advisor`/`leader` (labels "Coordenador Geral"/"Assessor"/"Liderança"); assessor vê só as Praças que administra; liderança = 1 registro por pessoa com N Praças e N organizações; votos por liderança×Praça (`declaredVotes` visível à liderança, `estimatedVotes` staff-only); demandas com workflow assessor→coordenador geral + comprovantes staff-only; tendência política manual (staff); comparativo multi-candidato/multi-ano por Praça; mapa divergente vermelho↔branco↔azul. Reset dos dados de campanha em produção (site público intocado). Plano-mestre: [`docs/plans/remodelagem-pracas.md`](../../../docs/plans/remodelagem-pracas.md). As entradas abaixo descrevem a era Núcleos — padrões de infra (locks, transações, consent por chave, shells, mapa, dados eleitorais) continuam válidos; superfícies e modelo de Núcleo foram substituídos. - **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`. - **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo. - **A2 entregue e mesclado em `main` (2026-07-18):** cadastro estático município↔ZE (`bahiaTseZones`, TSE 2024) + chips opt-in no formulário (`NucleusTerritoryAndZonesFields` / `territorySuggestions`); sem migration. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md). diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 00000000..5fc7123b --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,17 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "design-system-color", + "value": "#111827", + "files": [ + "src/collections/CampaignUser.ts" + ], + "createdAt": "2026-07-21T01:54:11.928Z", + "reason": "Pre-existing inline hex in the password-reset EMAIL HTML template; transactional email cannot use CSS tokens" + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 646e6cf2..43aee503 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si **Locked decisions — do not relitigate without a new concrete reason:** -- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `geral`, `coordenador`, and `lideranca`, all on the separate auth collection `campaignUser` (see "Campaign auth" below). +- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), and `leader` ("Liderança"), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the Praças they administer. +- **2026-07-20 remodel (in progress): "Praça" replaces "Núcleo Eleitoral" as the operational unit.** Praças are PREDEFINED (seeded, geography read-only): one per Bahia municipality, except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `plaza`. Leaderships are one record per person (`contact` UNIQUE) linked to N plazas and N organizations; vote pledges are per leadership×plaza with `declaredVotes` (leader-visible) vs `estimatedVotes` (staff-only, hidden from the leader). Campaign production data was reset by the remodel migration (public-site data untouched). Master plan: `docs/plans/remodelagem-pracas.md`. Sections below describing "nucleus" surfaces are being rewritten as the remodel lands. - Hosting stays on Vercel for now. No self-host/Coolify migration in progress. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. @@ -98,41 +99,39 @@ The internal `/campanha` area is gated by its own authentication, deliberately k - **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the ``/`` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier. - **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema. -## Campaign nuclei MVP +## Campaign Praças model (2026-07-20 remodel) -The first `/campanha` operational cycle is centered on `electoralNucleus`; a Núcleo is the campaign's operating unit and is not a TSE electoral zone. +`/campanha` is organized around `plaza` ("Praça") — the campaign's PREDEFINED operational territory. Master plan: `docs/plans/remodelagem-pracas.md`. -- **Collections:** `electoralNucleus` (territory, coordinators, TSE zone references, intelligence and vote estimates), `leadership` (unique `Contact`↔nucleus join with support status), `nucleusUpdate` (immutable field reports), and `campaignInvite` (single-use WhatsApp invite hashes). All are in admin group `Campanha`. -- **Canonical nucleus URLs:** nuclei use a unique canonical `slug` generated from `name` and route through `/campanha/nucleos/[slug]`. IDs stay internal. Names are immutable after creation so existing shared links remain durable without an alias table. -- **Territory:** Bahia is implicit. Since 2026-07-18 nuclei use `hasMany` arrays `regions` / `cities` / `neighborhoods` (plus `locality` / `territoryNotes`), validated against official Bahia identity territories (`src/lib/bahiaTerritories.ts`): `regions` is derived server-side from the selected cities, and neighborhoods require exactly one city. List filters stay single-select and query array membership with `{ equals }`. Migration `20260718_190559_territorio_multi_municipio_bairro` backfilled the old scalar fields (`docs/plans/territorio-multi-municipio-bairro.md`). **A2 (2026-07-18):** static municipality↔TSE zone map in `src/lib/bahiaTseZones.ts` (`tseZonesForCity` / `tseZonesForTerritory` / `citiesForTseZone`); the create/edit form coordinator `NucleusTerritoryAndZonesFields` offers opt-in `{label} +` chips from `buildTerritorySuggestions` (city/TI → missing zones; TI siblings and zone cities → municipalities). Manual `TseZoneInput` stays free (1–999); server validation does not require `tseZones` to match the official set; an amber client advisory may warn when a typed zone is outside the selected geography. Details: `docs/plans/zonas-por-municipio.md`. **B2 (2026-07-18):** static map geometries — see "Campaign map geometries (B2)" below (Leaflet surfaces are B3). -- **Role scope:** `geral` sees/manages all nuclei, assigns coordinators, and confirms estimates; `coordenador` sees assigned nuclei and manages their leaderships, updates, invites, and estimates; `lideranca` sees only nuclei linked through an engaged leadership record, creates own updates, and suggests estimates. -- **Sensitive fields:** `leadership.supportStatus`, internal notes, consent notes, and strategic nucleus intelligence are staff-only and are excluded from leadership view models. Revoking `engajado` revokes nucleus access immediately. Dashboard aggregates omit Payload-redacted missing statuses and fail closed on invalid enum values. -- **People and consent:** leaderships always reuse `Contact`; multi-collection writes are transactional. Self-service updates accept a strict whitelist derived from the invite token. Campaign consent is looked up only by the stable key `lideranca-autopreenchimento`. -- **Invites:** only SHA-256 token hashes are persisted. Absolute invite URLs require a valid `NEXT_PUBLIC_SITE_URL` (exact HTTPS DNS origin in production). Public invite pages are dynamic, `no-store`, `noindex,nofollow`, `no-referrer`, and have no third-party application scripts. Login invites are allowed only for engaged leaderships. -- **Vote estimates:** proposals never overwrite the confirmed value; each proposal carries a server-generated UUID version compared under advisory lock on confirm. Only a coordinator assigned to the nucleus or `geral` may confirm. -- **List overview (B1, 2026-07-18):** `/campanha/nucleos` renders an aggregate panel between the filters and the list (`NucleusListOverview` + `loadNucleusListOverviewData`): confirmed vote estimate total/%, coordinator coverage, and a 3-update preview, computed over the entire filtered set (`buildNucleusListWhere`, `pagination: false`, `overrideAccess: false`). Hidden when the filtered set is empty; `lideranca` gets a reduced select without pending-suggestion counts. -- **Share dialog (C1, 2026-07-18):** the nucleus detail header has a "Compartilhar" dialog (WhatsApp `wa.me` per recipient + copy link) that sends only the URL — it never creates invites or grants access. Recipients (gerais, assigned coordinators, `engajado` leaderships) are loaded on open via a read-only Server Action; the loader gates on nucleus access (`overrideAccess: false`) then reads name+phone privileged (`overrideAccess: true`) to avoid N+1 field-access checks. `canReadCampaignUserPhone` allows any campaign user to read `geral` phones. -- **PWA (D1, 2026-07-18):** `/campanha` is installable — manifest and service worker are served by route handlers under `/campanha` (script built in `src/utilities/campaignPwa.ts`), scoped to the vertical only (public site and `/admin` have no SW). Cache is named `campanha-` and swapped on deploy (`skipWaiting`/`claim`); navigation is network-first with an `/campanha/offline` fallback; RSC/Flight responses and `/campanha/convite/*` are never cached. `InstallPwaToast` prompts install on mobile (Android `beforeinstallprompt`, iOS step-by-step drawer, `sessionStorage` dismissal), and logout wipes the Cache API client-side before the server action runs. Push handlers are placeholders for D2. +- **Praça definition:** 436 seeded rows — one per Bahia municipality (415), except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171). TSE zones shared across municipalities (141, 162) or leaking outside the RMS (128, 185, 200) are cut at the municipal boundary, so every Praça maps to exact `cityCode × zones` election cells. The canonical catalog is `src/lib/plazaCatalog.ts` (slug/name identity frozen by `tests/fixtures/plaza-catalog.snapshot.json`); the migration `20260721_020109_remodel_plazas` seeds it. Nobody creates/edits plaza geography in the app — identity fields are admin-only; staff edit only strategy fields (goals, priority, intel, political trend) and the coordinator assigns `advisors`. +- **Collections (admin group `Campanha`):** `plaza` (strategy + advisors + manual `politicalTrend`), `leadership` (ONE per person: `contact` UNIQUE + `plazas` hasMany + `organizations` hasMany + staff-only `supportStatus`), `votePledge` (leadership×plaza UNIQUE: `declaredVotes` leader-visible vs `estimatedVotes` staff-only), `organization` (sindicatos/associações/movimentos with reverse leadership + actionPlan views), `campaignDemand` (leader-opened workflow `aberta → em_analise → escalada → aprovada|rejeitada` with staff-only `cost`/`receipts`), `plazaUpdate` (immutable field reports maintaining `plaza.lastUpdateAt` under advisory lock), `campaignInvite` (unchanged: single-use SHA-256 hashes hanging off `leadership`), plus adapted `supporter` (optional `plaza`) and `actionPlan` (single `plaza`, `advisors`, `organizations`, `deputyPresent`, `resultSummary`/`resultMedia`). +- **Roles:** `coordinator` ("Coordenador Geral") sees/manages everything and is the only one who assigns advisors and decides escalated demands; `advisor` ("Assessor") sees and manages ONLY plazas listed in `plaza.advisors` (list, map, dashboard, leaderships, pledges, demands are all scoped); `leader` ("Liderança") sees only plazas linked through their engaged leadership, declares own votes, opens demands, and writes own updates. +- **Vote pledge asymmetry (product-critical):** the leader INFORMS `declaredVotes` per linked plaza and may update it; staff record `estimatedVotes`/`estimateNote`, which field access denies to leaders (same technique as `supportStatus`). Leader-facing view models never aggregate estimates; staff aggregates use `estimatedVotes ?? declaredVotes` (`src/utilities/votePledgeData.ts`). Guarded by `tests/int/campaignVotePledge.int.spec.ts`. +- **Political trend:** manual conjuncture reading on the plaza (staff-only group `politicalTrend`: `favoravel|neutra|desfavoravel` + note + derived author/date). The numeric series is deliberately labeled "Evolução de votos" in the UI to keep "tendência" for the political reading. +- **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole). +- **URLs:** `/campanha/pracas[/slug]` (seeded slugs from the catalog), `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/planos`, `/campanha/apoiadores`. Route params stay English keys; slugs/enums stay pt data values. +- **Invites:** unchanged mechanics (hash-only persistence, `NEXT_PUBLIC_SITE_URL` origin, `no-store` public pages, login invites only for `engajado`); advisor scope now follows leadership↔plaza intersection. Consent still resolves only by stable key `lideranca-autopreenchimento` (fail closed). +- **PWA (D1):** unchanged — installable `/campanha`, versioned cache, offline fallback; push is still D2. ## Campaign supporters (C2) Nominal supporter registry backing the campaign's base of declared supporters (roadmap item C2; engineering ready and merged to `main` on 2026-07-18, production-blocked on the legal batch). One collection in admin group `Campanha`: -- **`supporter`** (`src/collections/Supporter.ts`) — join `Contact`↔campaign with **optional `nucleus`** (transversal base; aggregates by territory when linked). Fields: `contact` (required, indexed), `nucleus` (optional, indexed), `voteIntention` (select `certo | tende_a_certo | indeciso | outro`, indexed — **sensitive data**, field access restricted to `geral`/`coordenador`), paired consent fields for registration and vote intention (`consent`/`consentContentHash`/`consentedAt` and `voteIntentionConsent`/`voteIntentionConsentContentHash`/`voteIntentionConsentedAt`), `source` (`import_csv | manual | convite | evento`; v1 writes only the first two), `consentNote`, `notes` (staff-only), `createdBy` (readOnly). Uniqueness: `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)` (Postgres ≥15) via migration `20260718_222656_add_supporter`. A `beforeChange` hook sets `createdBy` and rejects a `contact` that is already a `leadership` of the same `nucleus`. +- **`supporter`** (`src/collections/Supporter.ts`) — join `Contact`↔campaign with **optional `plaza`** (transversal base; aggregates by Praça when linked). Fields: `contact` (required, indexed), `plaza` (optional, indexed), `voteIntention` (select `certo | tende_a_certo | indeciso | outro`, indexed — **sensitive data**, field access restricted to `coordinator`/`advisor`), paired consent fields for registration and vote intention (`consent`/`consentContentHash`/`consentedAt` and `voteIntentionConsent`/`voteIntentionConsentContentHash`/`voteIntentionConsentedAt`), `source` (`import_csv | manual | convite | evento`; v1 writes only the first two), `consentNote`, `notes` (staff-only), `createdBy` (readOnly). Uniqueness: `UNIQUE NULLS NOT DISTINCT (contact_id, plaza_id)` (Postgres ≥15) via migration `20260721_020109_remodel_plazas`. A `beforeChange` hook sets `createdBy` and rejects a `contact` whose leadership is already linked to the same `plaza`. - **Consent by stable key.** `campaignConsent.ts` was generalized to `getConsentByKey` / `requireConsentByKey` (fail-closed). The two keys are `apoiador-cadastro` (declared support) and `apoiador-intencao-voto` (vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern as `lideranca-autopreenchimento`. -- **Access.** `geral` sees/manages all; `coordenador` sees supporters of assigned nuclei (`nucleus in getAccessibleNucleusIds`); supporters without a nucleus are `geral`-only; `lideranca` has no access to the area. Delete is admin-only. Access helpers live in `campaignAccess.ts` (`canReadSupporter` / `canManageSupporter` / `canCreateSupporter`). -- **Server actions** (`src/app/(campaign)/campanha/actions/supporter.ts`): `createSupporter`, `setSupporterVoteIntention`, `previewSupporterImport` / `confirmSupporterImport` (CSV, `geral`-only, transactional with `req: { transactionID }`, dedup by phone via `contactPhoneInvariant`), and `removeSupporterData` (deletes the `supporter`; anonymizes/clears `Contact` PII only if no other join — `leadership`/`signature`/`subscription`/other `supporter` — references it). +- **Access.** `coordinator` sees/manages all; `advisor` sees supporters of administered plazas (`plaza in getAccessiblePlazaIds`); supporters without a plaza are `coordinator`-only; `leader` has no access to the area. Delete is admin-only. Access helpers live in `campaignAccess.ts` (`canReadSupporter` / `canManageSupporter` / `canCreateSupporter`). +- **Server actions** (`src/app/(campaign)/campanha/actions/supporter.ts`): `createSupporter`, `setSupporterVoteIntention`, `previewSupporterImport` / `confirmSupporterImport` (CSV, `coordinator`-only, transactional with `req: { transactionID }`, dedup by phone via `contactPhoneInvariant`), and `removeSupporterData` (deletes the `supporter`; anonymizes/clears `Contact` PII only if no other join — `leadership`/`signature`/`subscription`/other `supporter` — references it). - **UI** `/campanha/apoiadores` — list with KPIs (total / "Certo + Tende" / "Indecisos"), filters (vote intention, territory), detail with LGPD consent block + vote-intention segmented control (disabled until consent) + minimal share kit (`wa.me` + copy text), and a 3-step CSV import wizard (Upload → Preview → Confirm). Phone is required in v1. -- **C6 scale layer (merged to `main` 2026-07-19).** CSV import up to 5000 rows now completes in one transaction: `confirmSupporterImportRecord` acquires all phone advisory locks, then `bulkInsertSupporterImport` (`src/utilities/supporterImportBulk.ts`) inserts contacts + supporters via `payload.db.drizzle` on the Payload transaction session (`getPostgresTransactionDatabase`) in 500-row chunks with `ON CONFLICT DO NOTHING` on `(contact_id, nucleus_id)`. `Contact.enforceUniqueContactPhone` honors `context.skipContactPhoneInvariant` **fail-closed** (only inside an active `req.transactionID`), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the full `ok` set: `previewSupporterImportText` stages it in `supporterImportBatch` (`src/collections/SupporterImportBatch.ts`, admin-hidden, `geral`/admin only, migration `20260719_011015_add_supporter_import_batch`) and returns an HMAC-SHA256 single-use `importToken` (10-min TTL, actor-bound, `src/utilities/supporterImportToken.ts`); `confirmSupporterImportRecord` verifies the token and consumes the batch (delete after commit). The list KPI is a single `COUNT(*) FILTER` aggregate (`src/utilities/supporterListOverviewAggregate.ts`) mirroring `buildSupporterListWhere` + the access constraint, replacing 3× `payload.count`. Shared shells — `campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts` — replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the `/simplify` pass are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`). +- **C6 scale layer (merged to `main` 2026-07-19).** CSV import up to 5000 rows now completes in one transaction: `confirmSupporterImportRecord` acquires all phone advisory locks, then `bulkInsertSupporterImport` (`src/utilities/supporterImportBulk.ts`) inserts contacts + supporters via `payload.db.drizzle` on the Payload transaction session (`getPostgresTransactionDatabase`) in 500-row chunks with `ON CONFLICT DO NOTHING` on `(contact_id, plaza_id)`. `Contact.enforceUniqueContactPhone` honors `context.skipContactPhoneInvariant` **fail-closed** (only inside an active `req.transactionID`), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the full `ok` set: `previewSupporterImportText` stages it in `supporterImportBatch` (`src/collections/SupporterImportBatch.ts`, admin-hidden, `coordinator`/admin only, migration `20260719_011015_add_supporter_import_batch`) and returns an HMAC-SHA256 single-use `importToken` (10-min TTL, actor-bound, `src/utilities/supporterImportToken.ts`); `confirmSupporterImportRecord` verifies the token and consumes the batch (delete after commit). The list KPI is a single `COUNT(*) FILTER` aggregate (`src/utilities/supporterListOverviewAggregate.ts`) mirroring `buildSupporterListWhere` + the access constraint, replacing 3× `payload.count`. Shared shells — `campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts` — replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the `/simplify` pass are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`). - **Mandatory production blocker:** do not import real supporter data or capture vote intention until electoral counsel documents the LGPD art. 11 basis (vote intention is sensitive data), approves the versioned consent texts, and an admin creates `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'`. The app intentionally fails closed when these keys are absent. ## Campaign action plans (C3) Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to `main` on 2026-07-18). One collection in admin group `Campanha`: -- **`actionPlan`** (`src/collections/ActionPlan.ts`) — a calendarizable action/event (caminhada, comício, etc.) with its own territory (no link to `electoralNucleus`), task checklist, and update feed. Fields: `title`, canonical immutable `slug` (from `title`), `kind` / `status` enums in pt-BR (data values), `startAt` (optional only while `rascunho`; required to move to `planejado`/`confirmado`/`realizado`/`cancelado` — card shows "Data a definir" when absent), territory arrays `regions`/`cities`/`neighborhoods` (Bahia validation shared via `campaignTerritoryValidation.ts`; list/overview filters use `{ equals }` on arrays, A1 semantics), people (`coordinators` → `campaignUser`, `responsible` → `Contact`, `leadership` → `leadership` optional), `tasks` and `updates` as arrays in the MVP (append-only for updates; `doneAt`/`author`/`createdAt` derived server-side). No `Consent` (internal staff data). Migration `20260718_222832_add_action_plan`. -- **Access.** `geral` everything; `coordenador` where listed in `coordinators` (may create, auto-included); `lideranca` where `leadership` ∈ engaged — write limited to toggling `tasks.done` and appending `updates`. Domain writes go through `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`). -- **UI** `/campanha/planos` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, territory), cards with kind/status badge + date/time + municipality + TI chip + "Resp:" + task progress; detail with tabs Visão geral / Tarefas / Atualizações (pattern `NucleusTabNav`); new/edit forms. "Próximos eventos" blocks surface on the nuclei list overview and the dashboard. +- **`actionPlan`** (`src/collections/ActionPlan.ts`) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONE `plaza`, with task checklist, update feed and result record. Fields: `title`, canonical immutable `slug`, `kind` / `status` enums in pt-BR, `startAt` (required beyond `rascunho`), `plaza` (required) + free-text `locality`, `organizations` (hasMany — supporting orgs), `advisors` (staff responsible; ex-`coordinators`), `deputyPresent` (checkbox "Deputado presente"), `responsible` → `Contact`, `leadership` optional, `tasks`/`updates` arrays (derived fields server-side), and the action result: `resultSummary` + `resultMedia` (upload → `media`) + derived `resultRecordedBy/At` — queryable history for future activities in the same Praça. No `Consent` (internal staff data). +- **Access.** `coordinator` everything; `advisor` where listed in `advisors` OR the plan's `plaza` is administered (may create, auto-included); `leader` where `leadership` ∈ engaged — write limited to toggling `tasks.done` and appending `updates`. Domain writes go through `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`). +- **UI** `/campanha/planos` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, plaza), cards with kind/status badge + date/time + Praça + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações; result form appears for staff when the plan is `realizado`. ## Campaign map geometries (B2) @@ -146,7 +145,7 @@ Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). ## Election baseline data (TSE 2022) -Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. **A4 (merged to `main` 2026-07-18):** `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` / `computeGapVs2022` surface the baseline on the nucleus overview tab (`NucleusElectoralBaseline` + `NucleusInsights` Gap vs 2022) and a "Baseline 2022" card on the filtered nuclei list overview; ticket roles live in `BASELINE_TICKET_2022`. Scale follow-ups from the A4 `/simplify` pass are **A7** (`docs/plans/escala-dry-pos-a4.md`). +Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. Since the Praça remodel, the consumers are `plazaElectoralBaseline.ts` (per-plaza series/turnout), `plazaCandidateComparison.ts` (multi-candidate table) and `plazaMapData.ts` (map, incl. the diverging compare mode); ticket roles live in `BASELINE_TICKET_2022`. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope). `pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached. @@ -158,6 +157,7 @@ Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Thr 4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change. 5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically. 6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent. +7. **Praça remodel deploy note:** migration `20260721_020109_remodel_plazas` is DESTRUCTIVE for the campaign vertical (drops nucleus-era tables, wipes campaign rows, converts `campaignUser.role` values, seeds 436 plazas). Review its SQL before the first production build that includes it; public-site data is untouched. ## Core Principles diff --git a/PRODUCT.md b/PRODUCT.md index f2c7ec91..3463b662 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -12,13 +12,13 @@ web Two surfaces, one product. -**`/campanha` (primary for design defaults):** campaign coordination (`geral`, `coordenador`) and leaderships (`lideranca`) as first-class peers — staff need depth for territory, people, estimates, and agenda; leaderships need a simpler, phone-first path into their nucleus. Used in the field and on the go, often under time pressure through the 2026 calendar. +**`/campanha` (primary for design defaults):** campaign coordination (`coordinator` "Coordenador Geral", `advisor` "Assessor") and leaderships (`leader` "Liderança") as first-class peers — staff need depth for Praças (predefined territories: municipality, or TSE zone in Salvador/Camaçari), people, declared-vs-estimated votes, and agenda; leaderships need a simpler, phone-first path into their Praças. Advisors see only the Praças they administer. Used in the field and on the go, often under time pressure through the 2026 calendar. **Public site:** citizens and the base in Bahia first (news, petitions, donate, stay close to the mandate); institutional visitors second (press, partners, official voice). ## Product Purpose -**`/campanha`:** field operations and electoral intelligence as one job — nuclei, people, estimates, agenda, and share kits, plus a live picture of territory and vote gap so the team can prioritize action. Nuclei, people, and estimates live here as the system of record, even when outreach uses pragmatic channels. +**`/campanha`:** field operations and electoral intelligence as one job — Praças, people, vote pledges, organizations, demands, agenda, and share kits, plus a live picture of territory (map scaled by votes, year-over-year candidate comparison) so the team can prioritize action. Praças, people, and pledges live here as the system of record, even when outreach uses pragmatic channels. **Public site:** own the relationship with the base first (engagement channel the campaign controls); project the mandate’s official voice and credibility second. diff --git a/docs/plans/remodelagem-pracas.md b/docs/plans/remodelagem-pracas.md new file mode 100644 index 00000000..d98a5851 --- /dev/null +++ b/docs/plans/remodelagem-pracas.md @@ -0,0 +1,135 @@ +# Remodelagem da /campanha: Núcleo Eleitoral → Praça + +Status: entregue (código; deploy pendente — revisar SQL destrutivo antes do build de produção) +Atualizado em: 2026-07-21 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Remodelagem Praças", itens R0–R5) +Impeccable: C — vertical `/campanha` remodelada (rotas novas `/campanha/pracas`, `/campanha/organizacoes`, `/campanha/demandas`; adaptação de planos/apoiadores/dashboard) +Appetite: ~2,5–3 semanas eng (fases R0–R5), funcional antes de 16/08 (início da propaganda) +Responsável: — + +## Design (Impeccable) + +Âncoras: `PRODUCT.md` / `DESIGN.md` (register product — "Field Desk") · tema `data-theme='campaign'` · shells existentes (`CampaignPageShell`, `CampaignListPagination`, `campaignListUrl`, `campaignFormFields`, shadcn `src/components/ui`). + +Na implementação: shape compacto por superfície → craft → critique → polish (Fase R5). + +Brief compacto: + +- **Persona / contexto:** Alex (coordenador geral) aloca esforço entre 436 Praças; o Assessor opera só as Praças que administra, no celular, sob pressão; Lia (liderança) declara votos e abre demandas pelo PWA. +- **Job principal:** enxergar o mesmo quadro operacional por Praça (votos, lideranças, agenda, demandas) e agir no território certo. +- **Estratégia de cor:** Restrained (Field Desk). Exceção justificada: escala divergente do mapa comparativo (vermelho `#c51414` ↔ branco ↔ azul novo token). +- **Anti-goals:** não virar dashboard SaaS genérico; não expor à liderança números de staff (estimativas do assessor, tendência política, custos). + +## Contexto + +O MVP de Núcleos (ciclos 1–2, Trilhas A–E) modelou a unidade operacional como `electoralNucleus`: uma entidade criada manualmente, com geografia livre (arrays `regions`/`cities`/`neighborhoods`), natureza organizativa (`organizationKind`) e estimativa de votos única por núcleo (proposta→confirmada). Em 2026-07-20, feedback da coordenação de campanha invalidou três premissas: + +1. **A coordenação não se organiza em torno de núcleos abstratos, e sim de territórios pré-definidos** — municípios, exceto na RMS onde o delimitador é a zona eleitoral. Criar/editar geografia manualmente é atrito sem valor. +2. **O jargão real é "Assessor"**, não "coordenador" (que colide com "Coordenador Geral"). E "tendência" na campanha significa mudança de conjuntura política (prefeito aliado que virou adversário), não evolução de série numérica. +3. **Estimativa de votos é por liderança×território**, com assimetria: a liderança informa quantos votos traz; o assessor estima o valor real e a liderança nunca vê o ajuste. + +A decisão de produto (2026-07-20, confirmada com o usuário) é remodelar a vertical em torno de **"Praça"** (código: `plaza`), com reset dos dados de campanha em produção (não há dados reais na vertical; o site público permanece intocado). + +## Objetivos + +- As 436 Praças existem pré-definidas (seed via migration a partir de catálogo estático versionado); ninguém cria/edita geografia de Praça no app. +- Roles `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"); assessor vê e gerencia **somente** as Praças que administra. +- Liderança é 1 registro por pessoa (`contact` UNIQUE), vinculada a N Praças e N Organizações; declara votos por Praça (`votePledge.declaredVotes`); o assessor registra `estimatedVotes` invisível à liderança (field access nega leitura). +- Organizações (sindicatos, associações, etc.) com página própria mostrando lideranças associadas e Planos de Ação apoiados. +- Planos de Ação com Praça, organizações apoiadoras, "Deputado presente" e registro de resultado (texto + fotos/vídeos) consultável no histórico da Praça. +- Demandas abertas pela liderança (ou staff), revisadas pelo assessor, escaláveis ao coordenador geral, com custo e comprovantes staff-only. +- Tendência política manual (staff) por Praça; a série numérica E2 é renomeada para "Evolução de votos" na UI. +- Comparativo multi-candidato/multi-ano por Praça (tabela) e no mapa (escala divergente vermelho↔branco↔azul). +- Guardrails: access control por papel em toda leitura (`overrideAccess: false` com `user`); escrita multi-collection transacional com `req`; sem chave `Consent` nova (Onda 0 inalterada); migrations commitadas (`push: false`). + +## Decisões travadas + +- **Entidade principal "Praça", código `plaza`.** Tradução direta minimiza carga cognitiva; "Praça" evita colisão com Territórios de Identidade na UI. (Usuário, 2026-07-20.) **Rejeitado:** `territory` (não traduz "praça"; colide com identificadores TI existentes — `bahiaTerritories`, `regions`, `territoryForCity` permanecem intocados); manter "Núcleo" (não representa a organização real da coordenação). +- **436 Praças pré-definidas:** 415 municípios (todos menos Salvador e Camaçari) + 19 zonas de Salvador (ZE 1–19) + 2 de Camaçari (ZE 170/171). Zonas compartilhadas (141, 162) e que vazam da RMS (128, 185, 200) são **cortadas na divisa municipal** — cada município é sua própria Praça; dados TSE são município×zona, recorte exato. (Usuário, 2026-07-20.) **Rejeitado:** zona "pura" multi-município (unidades cruzando divisa municipal e vazando da RMS); zonas em todos os 13 municípios da RMS (11 têm zona única — viraria 1:1 com o município); manter geografia livre por núcleo. +- **Roles em inglês no valor do enum:** `coordinator` | `advisor` | `leader` (labels pt "Coordenador Geral" / "Assessor" / "Liderança"). Aproveita o reset; sem segundo "coordenador" não há ambiguidade. Migração converte `geral`→`coordinator`, `coordenador`→`advisor`, `lideranca`→`leader` preservando contas. (Usuário, 2026-07-20.) **Rejeitado:** manter valores pt (`assessor` etc.) — inconsistente com a decisão de reduzir carga cognitiva no código. +- **Reset dos dados de campanha em produção.** Migração destrutiva dropa e recria as collections da vertical campanha (núcleos, lideranças, atualizações, convites, apoiadores, planos); `campaignUser` e `Contact` preservados; site público intocado. (Usuário, 2026-07-20 — "apenas na vertical campanha; as publicações do site público são reais".) **Rejeitado:** migração núcleo→praça com preservação (não há dados reais que justifiquem o custo); coexistência `/nucleos` + `/pracas` (duas verticais em paralelo confundem o time). +- **Assimetria de votos: `declaredVotes` (liderança) vs `estimatedVotes` (staff-only).** A liderança informa; o assessor estima; a liderança lê apenas o próprio declarado. Field access nega leitura de `estimatedVotes`/`estimateNote` a `leader` (mesma técnica de `supportStatus`); view models de liderança nunca somam estimados. Agregados staff usam `estimatedVotes ?? declaredVotes`. (Usuário, 2026-07-20.) **Rejeitado:** fluxo sugerir→confirmar por Praça (não é o processo real); mostrar o ajuste à liderança (quebra a relação de campo). +- **Assessor vê somente as Praças que administra** (`plaza.advisors`), incluindo lista, mapa, agregados e dashboard. (Usuário, 2026-07-20.) **Rejeitado:** leitura global com gestão restrita (vazaria inteligência entre assessores). +- **Demandas com eixo financeiro na v1:** `cost` + `receipts[]` (upload → `media`), field access staff-only, disclaimer de controle interno (não substitui SPCE/TSE — base legal e limites já pesquisados em [demandas-campanha.md](demandas-campanha.md)). (Usuário, 2026-07-20.) **Rejeitado:** cortar comprovantes da v1. +- **Mapa comparativo divergente:** vermelho da plataforma (`#c51414`) onde Solla lidera, centro branco, azul onde o comparado lidera. (Usuário, 2026-07-20.) +- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`plaza`, `Plaza*`, `votePledge`, `campaignDemand`, `plazaUpdate`, `organization`), URLs/segmentos em pt (`/campanha/pracas`, `/campanha/organizacoes`, `/campanha/demandas`), labels/admin em pt-BR; enums de dados permanecem em pt (`municipio|zona`, `aberta|em_analise|...`). + +## Questões em aberto + +- **Valores do enum de tendência política?** **Opções:** `favoravel|neutra|desfavoravel` | escala 5 pontos | texto livre. **Recomendação:** 3 valores + nota obrigatória (validar jargão com a coordenação na âncora de quarta). +- **Contas `leader` órfãs pós-reset?** **Opções:** manter logins | desativar. **Recomendação:** manter contas (username/senha preservados) e revincular quando a nova liderança for cadastrada com o mesmo telefone. +- **Tom do azul do modo comparação?** **Recomendação:** novo token semântico no tema `campaign` com contraste AA sobre branco; definir no polish (R5). + +## Abordagem proposta + +```mermaid +flowchart TD + catalog["src/lib/plazaCatalog.ts
436 entradas estáticas"] --> seed["migration consolidada
drop campanha + seed plazas + roles"] + seed --> plaza["plaza (collection)"] + plaza --> leadership["leadership (contact UNIQUE,
plazas[], organizations[])"] + leadership --> pledge["votePledge (leadership x plaza,
declared vs estimated)"] + plaza --> update["plazaUpdate (feed imutável)"] + plaza --> demand["campaignDemand (workflow +
cost/receipts staff-only)"] + plaza --> plan["actionPlan (plaza, orgs,
deputyPresent, result)"] + org["organization"] --> leadership + org --> plan + election["electionTally/CandidateVote/Candidate
2014/2018/2022 (preservados)"] -.baseline, comparativo, mapa.-> plaza +``` + +Componentes principais (depth check: reusar shells/utilities existentes): + +- **`src/lib/plazaCatalog.ts`** — catálogo estático das 436 Praças (slug, nome, kind `municipio|zona`, city, region TI, ibgeCode, tseCityCode, zoneNumber?, tseZones[]), derivado de `bahiaTseZones`/`bahiaMunicipalityCodes`/`bahiaTerritories` + fixture `tests/fixtures/` + teste int de contagem/bijeção/consistência. +- **Collections** (`src/collections/`): `Plaza.ts`, `Leadership.ts` (reescrita), `VotePledge.ts`, `Organization.ts`, `CampaignDemand.ts`, `PlazaUpdate.ts` (ex-`NucleusUpdate`), `ActionPlan.ts` (alterada: `plaza`, `organizations`, `deputyPresent`, `result*`), `Supporter.ts` (nucleus→plaza), `CampaignUser.ts` (roles), `CampaignInvite.ts` (escopo por plazas). +- **`src/utilities/campaignAccess.ts`** — reescrita: `getAdministeredPlazaIds` (advisor), `getLeaderPlazaIds`, access por collection seguindo o padrão atual (Where por papel, cache em `req.context`). +- **Migração** `pnpm migrate:create remodel_plazas` — converte roles com `USING`, dropa tabelas da vertical, cria o novo schema e faz seed das 436 Praças (import do catálogo no migration, precedente Onda 0). +- **Geografia eleitoral** — `plazaElectionGeography` substitui `nucleusElectionGeography` (praça-município: cityCode + todas as zonas; praça-zona: cityCode + [zoneNumber]); baseline/série/gap/insights adaptados. +- **Superfícies** (`src/app/(campaign)/campanha/(app)/`): `pracas/` (lista + mapa com seletor de ano), `pracas/[slug]/` (tabs Visão geral / Eleições / Lideranças / Atualizações / Demandas), `organizacoes/`, `demandas/`, `planos/` adaptado, `apoiadores/` adaptado, dashboard reancorado. Server actions em `campanha/actions/` seguem o padrão transacional existente. +- **Mapa** — reusa `BahiaMap`/`ChoroplethMapPanel` (B3) com métrica por ano (2014/2018/2022 TSE; 2026 = `estimatedVotes ?? declaredVotes` agregado) e modo comparativo divergente (Fase R4). + +## Fases + +- **R0 Documentação** — este plano; roadmap reescrito; AGENTS/notebook/PRODUCT/CUSTOMER. +- **R1 Domínio** — catálogo + collections + access + migração + types verdes. +- **R2 Superfícies core** — pracas lista/detalhe/mapa v1, CRM liderança multi-Praça, votos declarados×estimados, dashboard, convites. +- **R3 Organizações, Planos, Demandas** — verticais novas + adaptação de planos. +- **R4 Inteligência** — comparativo multi-candidato, mapa divergente, tendência manual, rename série E2. +- **R5 Hardening** — testes por papel, responsividade 360/390/768/1440, critique/polish, Aikido, checklist de deploy (migração destrutiva revisada antes do build de produção). + +## Dependências + +- Reusa: `bahiaTseZones`/`bahiaMunicipalityCodes`/`bahiaTerritories`/`bahiaGeometries` (A2/B2), collections eleitorais + seeds 2014/2018/2022 (A3/E2), `BahiaMap`/choropleth (B3), locks transacionais (`postgresTransactionLocks`), `withPayloadTransaction`, `campaignConsent` (chaves existentes), shells de lista/form (C6/C9), PWA D1. +- Nenhum item externo bloqueia; Onda 0 (lote jurídico) segue paralelo e inalterado. + +## Não escopo + +- Polígonos por zona eleitoral no mapa (permanece como evolução futura do B4; v1 agrega zonas no polígono municipal com painel de detalhe). +- Push/notificações (D2, [notifications.md](notifications.md)); GOTV (C5); dobradinha automática 2026 (A6, reenquadrar pós-remodel); perfis IBGE (A8, reenquadrar). +- Exportação fiscal SPCE (documentado em [demandas-campanha.md](demandas-campanha.md)). +- Import CSV de lideranças/pledges (apoiadores mantém o wizard existente adaptado). + +## Rabbit holes + +- **Polígonos de zona (geocodificar seções).** Se alguém "só completar" o mapa de Salvador por zona: semanas de geoprocessamento frágil. **Mitigação:** agregação municipal + painel por zona; polígono de zona só com decisão explícita. +- **Cargo da liderança por organização.** Join com atributos (presidente, diretor…) explode o CRM. **Mitigação:** v1 é `hasMany` simples; atributo por vínculo só com pedido real. +- **Histórico auditável de tendência política.** v1 = valor atual + nota + autor/data; collection de histórico só se o coordenador geral pedir. +- **Renomear identificadores TI existentes.** `bahiaTerritories`/`regions`/etc. permanecem; renomear em massa é churn sem valor. +- **Vídeo grande no resultado do plano.** Upload via `media`/Blob tem limites de tamanho; documentar limite na UI, não construir pipeline de vídeo. +- **Paridade 1:1 da suíte de testes de núcleos.** Reescrever 488 testes int nucleus-based seria a cauda longa; portar cobertura por papel/fluxo crítico e apagar specs obsoletos, documentando o corte. + +## Adiado com gatilho + +- **Lista global de lideranças com filtros avançados.** Revisitar quando o CRM por Praça não bastar (pedido do coordenador geral ou >200 lideranças). +- **`demandUpdate` como collection separada.** Revisitar se o array `statusHistory` crescer além do razoável (>20 entradas por demanda). +- **Import em massa de pledges.** Revisitar quando houver planilha real de votos declarados por liderança. + +## Referências + +- `docs/roadmap.md` (seção "Remodelagem Praças") +- `docs/plans/demandas-campanha.md` — pesquisa legal/LGPD do eixo financeiro (reaproveitada) +- `docs/plans/mapa-bahia-geometrias.md` — decisões de geometria e limitação de zonas +- `docs/plans/baseline-eleitoral-tse.md` — modelo eleitoral por município×zona +- `src/collections/ElectoralNucleus.ts`, `Leadership.ts`, `NucleusUpdate.ts`, `ActionPlan.ts`, `Supporter.ts` — padrões (hooks de slug, locks, access) a portar +- `src/utilities/campaignAccess.ts`, `voteEstimate.ts`, `nucleusElectionGeography.ts`, `nucleusElectoralBaseline.ts` — base da reescrita +- `src/lib/bahiaTseZones.ts`, `bahiaMunicipalityCodes.ts`, `bahiaTerritories.ts`, `electionResults.ts`, `electionInsights.ts` +- AGENTS.md — Campaign auth, naming, transações, migrations (`push: false`), LGPD/Consent fail-closed +- `PRODUCT.md` / `DESIGN.md` — register product (Field Desk), Signal Red Rule, tokens `data-theme='campaign'` diff --git a/docs/roadmap.md b/docs/roadmap.md index bb461434..e5d056d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,226 +1,133 @@ # Roadmap — Teqo -Atualizado em: 2026-07-20 (A8+ fill-in pós-`/simplify`; A8 em implementação) +Atualizado em: 2026-07-20 (Remodelagem Praças R0–R5 registrada como caminho crítico; itens núcleo-cêntricos supersedidos/reenquadrados) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) -| Data | Marco | Consequência para o produto | -| ----------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------- | -| 20/07–05/08 | Convenções partidárias | Núcleos/coordenadores operando **agora** | -| 15/08 | Prazo final de registro de candidaturas | TSE publica candidaturas 2026 → destrava A6 (dobradinha) | -| 16/08 | Início da propaganda eleitoral | Base nominal, baseline e agenda em produção **antes** desta data | -| 09–23/10 | Propaganda gratuita rádio/TV | Congelar mudanças arriscadas | -| 04/10 | 1º turno | GOTV (C5) — confirmação de comparecimento da base nominal | -| 25/10 | Eventual 2º turno | Chapa majoritária; Solla decidido no 1º turno | +| Data | Marco | Consequência para o produto | +| ----------- | --------------------------------------- | --------------------------------------------------------------------------------------------- | +| 20/07–05/08 | Convenções partidárias | Remodelagem Praças em curso; assessores operando no modelo novo o quanto antes | +| 15/08 | Prazo final de registro de candidaturas | TSE publica candidaturas 2026 → destrava A6 (dobradinha) | +| 16/08 | Início da propaganda eleitoral | Vertical remodelada (Praças, demandas, agenda, base nominal) em produção **antes** desta data | +| 09–23/10 | Propaganda gratuita rádio/TV | Congelar mudanças arriscadas (~20/09) | +| 04/10 | 1º turno | GOTV (C5) — confirmação de comparecimento da base nominal | +| 25/10 | Eventual 2º turno | Chapa majoritária; Solla decidido no 1º turno | ## Princípios e decisões travadas - Ownership de audiência e portabilidade de dados; módulos reutilizáveis em outros contextos políticos. - Um único app Next.js: site `(frontend)`, admin `(payload)`, `/campanha` `(campaign)`. Sem Rust separado. Vercel por enquanto. - Doações só via CTA QueroApoiar (`apoiar.me/jorgesolla`). Pessoa = `Contact` + joins — nunca cadastro paralelo. -- Núcleo = unidade operacional; Zona TSE = referência oficial distinta. Sem disparo em massa (Res. TSE 23.610 art. 33 / Meta). +- **Praça = unidade operacional pré-definida** (2026-07-20): município, exceto Salvador (19 Praças-zona) e Camaçari (2 Praças-zona); zonas compartilhadas cortadas na divisa municipal. Ninguém cria/edita geografia no app. Substitui o Núcleo Eleitoral — plano [remodelagem-pracas.md](plans/remodelagem-pracas.md). +- Roles: `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"); assessor vê só as Praças que administra. Sem disparo em massa (Res. TSE 23.610 art. 33 / Meta). ## Onda 0 — caminho crítico para dados reais -Engenharia de núcleos / C2 / C3 / baseline / PWA / Trilha E parcial já em `main`. Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](plans/onda-0.md)); **hold de PII real** até o lote jurídico final. +Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](plans/onda-0.md)); **hold de PII real** até o lote jurídico final. Inalterada pela remodelagem (mesmas chaves `Consent`). 1. **Lote jurídico único** _(externo)_ — textos finais (substituem provisórios) + base LGPD art. 11: `lideranca-autopreenchimento`, `apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`, Aviso de Privacidade, avaliação RIPD. -2. **Smoke pós-deploy** — `NEXT_PUBLIC_SITE_URL` HTTPS, login `/campanha`, núcleo de teste; checklist no AGENTS.md. +2. **Smoke pós-deploy** — `NEXT_PUBLIC_SITE_URL` HTTPS, login `/campanha`, Praça de teste; checklist no AGENTS.md. 3. **Ativação com dados reais** assim que (1) liberar — lideranças/apoiadores reais e import em massa. -4. **Onboarding do time** — usuários `geral`/`coordenador`, primeiros núcleos, treino de campo. +4. **Onboarding do time** — usuários `coordinator`/`advisor`, primeiras Praças assumidas, treino de campo. **O0+** (escala/DRY pós-Onda 0) não bloqueia jurídico nem smoke fictício — [plano](plans/escala-dry-pos-onda0.md). -## Já entregue (resumo) +## Remodelagem Praças (R0–R5) — caminho crítico de produto ✅ (código pronto; deploy pendente) -- **Núcleos MVP + Ciclo 2** — auth/`campaignUser`, território A1/A2, baseline A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3. -- **Operação** — C2 (eng. pronta; prod. ↔ Onda 0), C3 agenda, C6–C9 escala apoiadores/planos. -- **Trilha A (parcial)** — A5 conversão + classificação territorial + alavancagem/virada + mobilização (brancos/nulos); A7 F1 agregação federal no detalhe. -- **Trilha E (parcial)** — E1 metas/prioridade + E3 estratégia manual; E2 série TSE 2014/2018 + tendência; E7 F2 int loader; **E4 import planilha cortado** (decisão de produto: dados via UI manual, sem `pnpm db:seed:mapa`). -- **Fill-ins** — visitados recentemente, reset senha + perfil, Field Desk polish (Impeccable); B5 F1 lazy geometrias (com B3). +Feedback da coordenação (2026-07-20) invalidou o modelo de Núcleo: a campanha se organiza por territórios pré-definidos ("Praças"), jargão é "Assessor", votos são declarados por liderança×Praça e estimados pelo assessor (assimetria), demandas nascem da liderança com workflow de aprovação, tendência é conjuntura política manual, e a análise-chave é comparar candidatos por Praça através dos anos. Reset dos dados de campanha em produção (sem dados reais na vertical). Plano-mestre: [remodelagem-pracas.md](plans/remodelagem-pracas.md). -## Próximos — Campanha (`/campanha`) - -### Por trilha (só abertos) - -- **A** — A5 restante: inteligência competitiva ainda sem insight no produto · [competitiva](plans/insight-inteligencia-competitiva.md) -- **A** — A6 dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · [plano](plans/insight-dobradinha-2026.md) -- **A** — A7 F2–F5: `cityCode` TSE, Alert/Progress DRY, fetch 2022 único lista+mapa, batch flip na lista · gatilho F4: pós-B3 ✓; F5: pós-A5 · [plano](plans/escala-dry-pos-a4.md) -- **A** — A8 perfil médio do eleitorado (IBGE) na aba Eleitorado sem sobrescrever manuais · [plano](plans/perfil-eleitorado-ibge.md) -- **A** — A8+ escala pós-A8: lazy demographics, cobertura parcial, size budget · gatilho: merge A8 · [plano](plans/escala-dry-pos-a8.md) -- **B** — B4 camada de zonas TSE no mapa coroplético · [mapa](plans/mapa-bahia-geometrias.md) -- **B** — B5 F2–F3: cache CLI compartilhado + factory mun/TI · [plano](plans/escala-dry-pos-b2.md) -- **B** — B6 `BahiaMap` setStyle incremental ao trocar métrica · gatilho: troca frequente no overview · [plano](plans/escala-dry-pos-b3.md) -- **C** — C4 demandas de campanha (CRM de pedidos + custo) · [plano](plans/demandas-campanha.md) -- **C** — C10 lista de apoiadores sem N+1 de access / forms com `errorProps` · gatilho: base nominal crescendo · [plano](plans/escala-dry-pos-c9.md) -- **C** — C11 feed/loaders de planos sem O(n) no detalhe · gatilho: agenda com uso real / volume medido · [plano](plans/escala-dry-pos-c7.md) -- **D** — D2 push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md) -- **E** — E6 aggregate metas/prioridade + DRY geo/UI na lista · gatilho: muitos núcleos / lista lenta · [plano](plans/escala-dry-pos-e1.md) -- **E** — E7 F1/F3/F4 + extensões F2 pós-A5 (leverage/flip/classification/**mobilization** int, trend VM único, tipos, helpers) · [plano](plans/escala-dry-pos-e2.md) -- **Débitos / fill-ins** — O0+, VR+, RS+, FD+, FD2, listas globais, higiene PascalCase (ver abaixo) - -### A validar (assumptions) - -- **C5** — operação dia D / GOTV (check-off da base em 04/10) — validar com produto; design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png); sem plano ainda -- **E5** — Salvador por bairro (`votacao_secao` + DE-PARA) — valor pós-eleição? -- **FD2** — prioridades de UX pós-critique (bulk fila, glossário, …) — validar com produto · [plano](plans/field-desk-ux-pos-critique.md) -- **Listas globais** — lideranças/atualizações/territórios no raiz — sem plano detalhado - -### Referências de design (só itens abertos com ref) - -| Item | Design | Plano | -| ---------------------------- | ------------------------------ | --------------------------------------------------------------------- | -| A5 competitiva | `Baseline-Eleitoral-2022` | [competitiva](plans/insight-inteligencia-competitiva.md) | -| A6 Dobradinha 2026 | — (encomendar) | [insight-dobradinha-2026.md](plans/insight-dobradinha-2026.md) | -| A8 Perfis eleitorado IBGE | — (reusa aba Eleitorado) | [perfil-eleitorado-ibge.md](plans/perfil-eleitorado-ibge.md) | -| C4 Demandas | — (encomendar) | [demandas-campanha.md](plans/demandas-campanha.md) | -| C5 GOTV _(validar)_ | `Dia-D-GOTV` | sem plano ainda | -| D2 Notificações | `Notificacoes-PWA` | [notifications.md](plans/notifications.md) | - -Paleta dos PNGs UX Pilot é legado — implementação usa tokens `data-theme='campaign'`. - -### Grafo de dependências (abertos + predecessores mínimos) +| Fase | Escopo | Status | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| R0 | Documentação (plano-mestre, roadmap, AGENTS/notebook/PRODUCT/CUSTOMER) | entregue 2026-07-21 | +| R1 | Domínio: catálogo 436 Praças, collections (`plaza`, `leadership`, `votePledge`, `organization`, `campaignDemand`, `plazaUpdate`), access, migração consolidada `20260721_020109_remodel_plazas` | entregue 2026-07-21 | +| R2 | Superfícies core: `/campanha/pracas` lista+detalhe+mapa (seletor de ano), CRM `/campanha/liderancas` multi-Praça, votos declarados×estimados, dashboard, convites | entregue 2026-07-21 | +| R3 | `/campanha/organizacoes`, planos com Praça/orgs/presença/resultado, `/campanha/demandas` com workflow e comprovantes | entregue 2026-07-21 | +| R4 | Inteligência: comparativo multi-candidato, mapa divergente (vermelho↔branco↔azul), tendência política manual, rename série E2 → "Evolução" | entregue 2026-07-21 | +| R5 | Hardening: testes por papel (unit 187 / int 306 / e2e 9 verdes), Aikido, checklist de deploy (migração destrutiva revisada) | entregue 2026-07-21 — critique/polish visual fino registrado como R6 | ```mermaid flowchart TD subgraph Onda0["Onda 0 — produção"] JUR["Lote jurídico único
(Consent finais + privacidade)"] - O0DRY["O0+ Escala/DRY pós-Onda 0"] end - subgraph TrilhaA["Trilha A"] - A4["A4 Baseline + Gap ✓"] - A5done["A5 conversão/classif./alavancagem/mobilização ✓"] - A5open["A5 competitiva"] - A7["A7 F2–F5 escala pós-A4"] - A6["A6 Dobradinha 2026"] - A8["A8 Perfis IBGE"] - A8DRY["A8+ escala pós-A8"] + subgraph Remodel["Remodelagem Praças"] + R1["R1 Domínio"] + R2["R2 Superfícies core"] + R3["R3 Orgs + Planos + Demandas"] + R4["R4 Inteligência"] + R5["R5 Hardening"] end - subgraph TrilhaB["Trilha B"] - B3["B3 Leaflet ✓"] - B5["B5 F2–F3 cache/factory"] - B6["B6 setStyle Leaflet"] - B4["B4 Zonas TSE no mapa"] - end - - subgraph TrilhaC["Trilha C"] - C2["C2 Cadastro nominal ✓"] - C3["C3 Agenda ✓"] - C10["C10 Escala pós-C9"] - C11["C11 Escala pós-C7"] - C4["C4 Demandas"] - C5["C5 GOTV _(validar)_"] - end - - subgraph TrilhaD["Trilha D"] - D1["D1 PWA ✓"] - D2["D2 Push + sino"] - end - - subgraph TrilhaE["Trilha E"] - E1["E1+E3 Metas/estratégia ✓"] - E2["E2 Tendência ✓"] - E6["E6 Escala pós-E1"] - E7["E7 Escala pós-E2"] - E5["E5 Salvador bairro _(validar)_"] - end - - A4 --> A7 - A4 --> A5open - A5done -.mesmo loader.-> A7 - A4 --> A6 - A8 -.após merge.-> A8DRY - TSE2026["TSE candidaturas 2026
(após 15/08)"] --> A6 - B3 --> B6 - B3 --> B4 - A7 -.fetch 2022.-> B3 - JUR --> C2prod["C2 dados reais"] - C2 --> C2prod - C2 --> C5 - C3 --> C11 - C3 -.relação opcional.-> C4 - C10 -.forms DRY.-> C11 - D1 --> D2 + R1 --> R2 --> R3 --> R5 + R2 --> R4 --> R5 + JUR --> C2prod["C2 dados reais
(base nominal)"] + R2 --> C2prod + TSE2026["TSE candidaturas 2026
(após 15/08)"] --> A6["A6 Dobradinha 2026
(reenquadrada p/ Praça)"] + R4 -.baseline por Praça.-> A6 + R2 --> D2["D2 Push + sino"] JUR -.chave push.-> D2 - E1 --> E6 - E2 --> E7 - E2 -.dataset.-> E5 + C2prod --> C5["C5 GOTV (validar)"] ``` -### Sequência por janela (só pendentes) +### Sequência por janela -**Janela 1 — agora → 05/08 (convenções)** +**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo. -| Ordem | Item | Plano | Depende de | Paralelizável com | -| ----- | ----------------------------------------- | -------------------------------------------------- | ----------------------- | ------------------- | -| 1 | Onda 0 (lote jurídico + smoke + onboarding) | [onda-0.md](plans/onda-0.md) | engenharia ✓ | tudo | -| 2 | C2 dados reais (após Consent finais) | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) | Onda 0 jurídico | A7, E6, C10, C11 | -| 3 | C10 / C11 se base ou agenda crescerem | [C10](plans/escala-dry-pos-c9.md) · [C11](plans/escala-dry-pos-c7.md) | C9 ✓ / C7 ✓ | E6, A7, O0+ | -| 4 | E6 F1 se lista de núcleos ficar lenta | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) | E1+E3 ✓ | E7, A7, C10 | +**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga. -**Janela 2 — 05/08 → 16/08 (pré-propaganda)** +**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (mapa com troca de ano/métrica frequente), débitos sobreviventes (abaixo). -| Ordem | Item | Plano | Depende de | Paralelizável com | -| ----- | ----------------------------------------- | -------------------------------------------------- | ----------------------- | ------------------- | -| 5 | A5 inteligência competitiva | [competitiva](plans/insight-inteligencia-competitiva.md) | A4 ✓ | A7, A8, C4, D2 | -| 6 | A7 F2–F5 | [escala-dry-pos-a4.md](plans/escala-dry-pos-a4.md) | A4 ✓ | A5, A8, C4 | -| 7 | A8 perfis IBGE | [perfil-eleitorado-ibge.md](plans/perfil-eleitorado-ibge.md) | A1 ✓ | A5, A7 | -| 8 | E7 F1/F3/F4 | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) | E2 ✓ | E6, A7 | -| 9 | C4 Demandas | [demandas-campanha.md](plans/demandas-campanha.md) | A1 ✓ (suave: C3) | A5, A7, D2 | -| 10 | D2 Notificações | [notifications.md](plans/notifications.md) | D1 ✓ + chave push | A5, A7, C4 | -| 11 | B5 F2–F3 / B6 | [B5](plans/escala-dry-pos-b2.md) · [B6](plans/escala-dry-pos-b3.md) | B2 ✓ / B3 ✓ | A7 | - -**Janela 3 — 16/08 → set** - -| Ordem | Item | Plano | Depende de | Paralelizável com | -| ----- | ------------------- | ------------------------------------------------------------- | ----------------- | ----------------- | -| 13 | B4 Zonas no mapa | [mapa-bahia-geometrias.md](plans/mapa-bahia-geometrias.md) | A2 ✓ + B3 ✓ | A6 prep, C5 prep | - -**Janela 4 — set → 04/10** - -| Ordem | Item | Plano / design | Depende de | -| ----- | ----------------------------------------- | ------------------------------------------------- | ----------------------------------- | -| 14 | A6 Dobradinha 2026 | [insight-dobradinha-2026.md](plans/insight-dobradinha-2026.md) | A4 ✓ + TSE pós-15/08 | -| 15 | C5 GOTV _(validar)_ | design `Dia-D-GOTV` | C2 dados reais | -| 16 | Congelamento ~20/09 | — | só bugfix / dados | - -### Fill-ins abertos - -- **O0+** — revalidate globals, módulo de chaves Consent, testes SQL, DRY Lexical/layout · [plano](plans/escala-dry-pos-onda0.md) -- **A8+** — lazy `bahiaMunicipalityDemographics`, cobertura parcial explícita, size budget int · gatilho: A8 ✓ · [plano](plans/escala-dry-pos-a8.md) -- **VR+** — refresh bfcache/storage do painel recentes + DRY de lista · gatilho F1: painel stale · [plano](plans/escala-dry-pos-visitados-recentemente.md) -- **RS+** — auth read leve + shells/campos de senha compartilhados · gatilho F1: `/campanha` lento sob carga · [plano](plans/escala-dry-pos-reset-senha-perfil.md) -- **FD+** — shell size / heading / strips Field Desk · gatilho strips: 3ª strip no home ou Fase 3 VoteGoals · [plano](plans/escala-dry-pos-field-desk.md) -- **FD2** _(validar)_ — bulk fila Sem coordenador, glossário, outline, empty coordenador · [plano](plans/field-desk-ux-pos-critique.md) -- **Listas globais** _(validar)_ — lideranças / atualizações / territórios no nível raiz -- **Higiene PascalCase** — varredura de componentes legados - -### Itens cortados - -| Item | Cortado em | Motivo | Alternativa | -| ---- | ---------- | ------ | ----------- | -| **E4** import único da planilha (`pnpm db:seed:mapa`) | 2026-07-19 | Decisão de produto: não importar automaticamente da planilha de exemplo; preferência por recolocar dados manualmente | Preencher metas, prioridade, dobradinhas e encaminhamentos via UI E1+E3 em `/campanha/nucleos` | - -Plano histórico: [mapa-projecao-municipios.md](plans/mapa-projecao-municipios.md) (seção E4 marcada como cortada). +**Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados). ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent), C2 dados reais, C3 agenda (já ✓), A4 baseline (já ✓), E1+E3 paridade mínima com a planilha (já ✓) — risco legal, base nominal, operação e instrumento de alocação. +**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo). -**Cortes seguros** (se o prazo apertar): E5; B4; B6; B5 F2–F3; A8; **A8+**; E7 F1/F3/F4; E6 F2–F4 (preferir F1 se lista lenta); A7 F2–F5 (F4 vale mais com mapa na lista; F5 se filtros ≤10 núcleos); A5 competitiva; C4; D2 push (manter sino); C10/C11 se base/agenda pequenas; O0+/VR+/RS+/FD+ fases cosméticas; FD2 Fase 5 motion; fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; débitos/fill-ins. + +## Já entregue (resumo) + +- **Era Núcleos (2026-07-15 → 2026-07-20)** — MVP + Ciclo 2 (auth `campaignUser`, território A1/A2, baseline TSE A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3), C2 apoiadores (eng.), C3 agenda, C6–C11 escala, E1+E3 metas/estratégia, E2 série TSE 2014/2018/2022, A5 conversão/classificação/alavancagem/mobilização, A7 F1–F2, A8 perfis IBGE, fill-ins (reset senha/perfil, visitados recentes, Field Desk polish). Infra e padrões (locks, transações, consent por chave, shells, mapa, dados eleitorais) **são reaproveitados pela remodelagem**; as superfícies e o modelo de Núcleo são substituídos. +- **Plataforma** — local Postgres + guards, migrations baselined, posts/tags do site público com cache `posts`, Onda 0 textos provisórios + `/privacidade`. + +## Supersedidos pela remodelagem (2026-07-20) + +| Item antigo | Destino | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| A5 competitiva (parte aberta) | Absorvido por **R4** (comparativo multi-candidato por Praça é a versão pedida pela coordenação) | +| A7 F3–F5 (escala pós-A4) | Morre com os loaders de núcleo; fetch único lista+mapa vira requisito nativo do mapa de Praças (R2) | +| A8+ (escala pós-A8) | Reavaliar pós-R2 se o Eleitorado IBGE for portado ao detalhe da Praça | +| B4 zonas TSE no mapa | Rabbit hole documentado no plano-mestre (agregação municipal + painel por zona; polígono de zona só com decisão explícita) | +| C4 demandas | Absorvido por **R3** (agora nascem da liderança, com workflow assessor→coordenador geral e comprovantes staff-only) | +| C10 / C11 (escala apoiadores/planos) | Reavaliar pós-R2/R3 nas superfícies adaptadas; gatilhos originais (base nominal crescendo / volume medido) continuam valendo | +| E5 Salvador por bairro | Praças-zona de Salvador cobrem a granularidade; bairro só com pedido real pós-eleição | +| E6 (escala pós-E1) / E7 (escala pós-E2) | Overview de núcleos e trend VM morrem; débitos int reavaliar pós-R4 | +| FD+ / FD2 (Field Desk pós-critique) | Superfícies redesenhadas em R2; princípios (glossário inline, filas, bulk) entram no critique de R5 | +| VR+ (visitados recentemente) | Painel portado para Praças em R2; débito de refresh mantém gatilho original | +| Listas globais (lideranças/atualizações) | CRM de liderança já nasce global em R2 (liderança é 1 registro por pessoa) | + +## Próximos — sobreviventes e pós-remodelagem + +- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md) +- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md) +- **B6** `BahiaMap` setStyle incremental ao trocar métrica/ano · gatilho: troca frequente no mapa de Praças · [plano](plans/escala-dry-pos-b3.md) +- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais +- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md) +- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 +- **Fill-ins:** O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase ## Bloqueadores atuais -| Item | Status | Fonte | -| -------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------ | -| Lote jurídico final LGPD (Consent + privacidade + RIPD) — PII real em hold | **caminho crítico para dados reais** | notebook; onda-0; AGENTS | -| RBAC em `users` (admin Payload) antes de abrir `/admin` a equipe maior | pendente | AGENTS Known Gap #1 | -| Consent público ainda por ID numérico (`submitWhatsapp.ts` etc.) | pendente | AGENTS Known Gap #2 | -| Collection `Pages` + hero/copy da home editáveis | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 | +| Item | Status | Fonte | +| ------------------------------------------------------------------------------------------------ | ------------------------------------ | ---------------------------------------------------- | +| Lote jurídico final LGPD (Consent + privacidade + RIPD) — PII real em hold | **caminho crítico para dados reais** | notebook; onda-0; AGENTS | +| Deploy da remodelagem Praças (migração destrutiva `remodel_plazas` — revisar SQL antes do build) | **pronto para deploy** | [remodelagem-pracas.md](plans/remodelagem-pracas.md) | +| RBAC em `users` (admin Payload) antes de abrir `/admin` a equipe maior | pendente | AGENTS Known Gap #1 | +| Consent público ainda por ID numérico (`submitWhatsapp.ts` etc.) | pendente | AGENTS Known Gap #2 | +| Collection `Pages` + hero/copy da home editáveis | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 | ## Site público @@ -245,14 +152,15 @@ Plano histórico: [mapa-projecao-municipios.md](plans/mapa-projecao-municipios.m ## Fora de escopo (por enquanto) - Serviço Rust separado; self-host/Coolify enquanto Vercel atender; doações in-app -- Núcleo = Zona TSE; PWA do site/`/admin`; PostGIS sem query espacial real +- Polígonos por zona eleitoral (geocodificação de seções); Praça = seção eleitoral +- PWA do site/`/admin`; PostGIS sem query espacial real - WhatsApp Business API / disparo em massa; previsão estatística de votos neste ciclo +- Import automático de planilhas de projeção (decisão E4 mantida: dados estratégicos via UI) ## Fontes - [`AGENTS.md`](../AGENTS.md) — decisões travadas, Known Gaps, checklist -- [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc) — status operacional Núcleos +- [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc) — status operacional (era Núcleos → Praças) - [`README.md`](../README.md) — missão -- [`docs/plans/*.md`](plans/) — planos por item -- `docs/sheets/*.xlsx` — planilhas Trilha E (estrutura; dados eleitorais vêm do TSE) +- [`docs/plans/*.md`](plans/) — planos por item ([remodelagem-pracas.md](plans/remodelagem-pracas.md) é o plano-mestre vigente) - Res. TSE 23.760/2026 · Res. TSE 23.610/2019 art. 33 · Politipédia AVM diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index f002a977..5ca388ac 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -31,7 +31,7 @@ export type SupporterRemoveFormState = { const safeVoteIntentionMessages = [ 'Consentimento de intenção de voto ainda não configurado.', - 'Somente a coordenação pode gerenciar apoiadores.', + 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', ] as const const toMessageOnlyState = ( diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index c22749b8..b45c8562 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -13,7 +13,7 @@ import { VoteIntentionControl } from '@/components/campaign/VoteIntentionControl import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { isCampaignCoordinator } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadSupporterDetailConsentData, @@ -70,7 +70,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag - {isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'} + {isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}

{supporter.name}

@@ -109,18 +109,18 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag

{supporter.phoneDisplay || '—'}

{supporter.email ?

{supporter.email}

: null}

- {supporter.nucleusName ? ( + {supporter.plazaName ? ( <> - Núcleo:{' '} + Praça:{' '} - {supporter.nucleusName} + {supporter.plazaName} ) : ( - Sem núcleo vinculado + Sem Praça vinculada )}

diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx index f02f11c6..af1ec7e0 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx @@ -5,13 +5,13 @@ import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { SupporterImportWizard } from '@/components/campaign/SupporterImportWizard' import { Button } from '@/components/ui/button' import { getCampaignUser } from '@/utilities/campaignAuth' -import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { isCampaignCoordinator } from '@/utilities/campaignAccess' export default async function ImportSupportersPage() { const user = await getCampaignUser() if (!user) redirect('/campanha/login') - if (!isCampaignGeneral(user)) redirect('/campanha/apoiadores') + if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores') return (
@@ -19,7 +19,7 @@ export default async function ImportSupportersPage() { - Importação · Coordenação geral + Importação · Coordenador Geral

Importar apoiadores via CSV

Envie uma planilha, confira a prévia e confirme a importação em lote. diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index faa5a8dd..1a0629fe 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -26,15 +26,15 @@ export type SupporterFormValues = { phone?: string email?: string city?: string - nucleus?: string + plaza?: string voteIntention?: string } const safeActionMessages = [ - 'Esta pessoa já está cadastrada como apoiador neste núcleo.', + 'Esta pessoa já está cadastrada como apoiador nesta Praça.', 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', - 'Somente a coordenação pode gerenciar apoiadores.', - 'Somente a coordenação geral pode cadastrar apoiadores sem núcleo.', + 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', + 'Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.', 'Consentimento de cadastro de apoiador ainda não configurado.', 'Consentimento de intenção de voto ainda não configurado.', ] as const @@ -65,20 +65,20 @@ export const createSupporterFormAction = async ( phone: optionalFormText(formData, 'phone'), email: optionalFormText(formData, 'email'), city: optionalFormText(formData, 'city'), - nucleus: optionalFormText(formData, 'nucleus'), + plaza: optionalFormText(formData, 'plaza'), voteIntention: optionalFormText(formData, 'voteIntention'), } const phone = sanitizeBrazilianPhoneInput(values.phone ?? '') const voteIntention = values.voteIntention?.trim() || undefined - const nucleus = nullableRelationshipFormValue(formData, 'nucleus') + const plaza = nullableRelationshipFormValue(formData, 'plaza') const input = supporterCreateSchema.parse({ name: values.name ?? '', phone, email: values.email, city: values.city, - ...(nucleus ? { nucleus } : {}), + ...(plaza ? { plaza } : {}), ...(voteIntention ? { voteIntention } : {}), consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined, voteIntentionConsentAccepted: voteIntention diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx index d1eff1c5..5e71f028 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -7,7 +7,7 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { SupporterForm } from '@/components/campaign/SupporterForm' import { Button } from '@/components/ui/button' -import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { isCampaignCoordinator } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadSupporterCreatePageData } from '@/utilities/supporterPageData' import { canAccessSupporterArea } from '@/utilities/supporterUi' @@ -27,7 +27,7 @@ export default async function NewSupporterPage() { ← Voltar para apoiadores - {isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'} + {isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}

Novo apoiador

@@ -37,10 +37,10 @@ export default async function NewSupporterPage() {

) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index 899fc8f2..af5e6be9 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -19,7 +19,7 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/Empty' -import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { isCampaignCoordinator } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadSupportersPageData } from '@/utilities/supporterPageData' import { @@ -42,7 +42,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro const rawSearchParams = await searchParams const now = new Date() - const { result, state, redirectHref, nucleusOptions, overview } = await loadSupportersPageData( + const { result, state, redirectHref, plazaOptions, overview } = await loadSupportersPageData( payload, user, rawSearchParams, @@ -55,16 +55,16 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro

Apoiadores

- Base nominal de apoio com intenção de voto e vínculo opcional a núcleos. + Base nominal de apoio com intenção de voto e vínculo opcional a Praças.

- {isCampaignGeneral(user) - ? 'Coordenação geral · todos os apoiadores' + {isCampaignCoordinator(user) + ? 'Coordenador Geral · todos os apoiadores' : getSupporterScopeLabel(result.totalDocs)}
- {isCampaignGeneral(user) ? ( + {isCampaignCoordinator(user) ? ( +
+

{demand.title}

+ + {campaignDemandStatusLabels[demand.status]} + +
+

+ {campaignDemandKindLabels[demand.kind]} ·{' '} + + {demand.plazaName} + + {demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '} + {dateTimeFormatter.format(new Date(demand.createdAt))} +

+ + + {demand.description ? ( +
+

{demand.description}

+
+ ) : null} + + {demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? ( +
+

Nota da decisão

+

+ {demand.decisionNote} +

+ {demand.decidedAt ? ( +

+ Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))} +

+ ) : null} +
+ ) : null} + + {isStaffView ? ( + <> + + +
+

+ Controle interno +

+

+ Custo:{' '} + {demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'} +

+ {demand.receipts.length ? ( + + ) : ( +

Nenhum comprovante anexado.

+ )} + {demand.statusHistory.length ? ( +
+

Histórico

+
    + {demand.statusHistory.map((entry, index) => ( +
  • + {campaignDemandStatusLabels[entry.status]} + {entry.authorName ? ` — ${entry.authorName}` : ''} + {entry.createdAt + ? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}` + : ''} + {entry.note ? ` · ${entry.note}` : ''} +
  • + ))} +
+
+ ) : null} +
+ + ) : ( +
+

+ {demand.status === 'aberta' + ? 'Sua demanda foi registrada e aguarda análise da assessoria.' + : demand.status === 'em_analise' || demand.status === 'escalada' + ? 'Sua demanda está em análise pela coordenação.' + : 'Sua demanda foi decidida — veja a nota acima.'} +

+
+ )} + + ) +} diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts new file mode 100644 index 00000000..15a0ec14 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts @@ -0,0 +1,38 @@ +'use server' + +import { redirect } from 'next/navigation' + +import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand' +import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData' +import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +export const createDemandFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + let createdSlug: string | null = null + try { + const rawKind = requiredFormText(formData, 'kind') + const demand = await createCampaignDemand({ + title: requiredFormText(formData, 'title'), + kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind) + ? (rawKind as CampaignDemandKind) + : 'outro', + description: optionalFormText(formData, 'description'), + plaza: requiredRelationshipFormValue(formData, 'plazaId'), + }) + createdSlug = demand.slug + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages: ['Somente lideranças engajadas podem abrir demandas.'], + genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.', + }) + } + + redirect(`/campanha/demandas/${createdSlug}`) +} diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx new file mode 100644 index 00000000..0e9c8571 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -0,0 +1,39 @@ +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 { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { DemandForm } from '@/components/campaign/DemandForm' +import { Button } from '@/components/ui/button' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadPlazaOptions } from '@/utilities/campaignRelationOptions' +import { createDemandFormAction } from './formActions' + +export default async function NewDemandPage() { + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + if (!user) redirect('/campanha/login') + + const plazaOptions = await loadPlazaOptions(payload, user) + + return ( + +
+ +

Nova demanda

+

+ Descreva a necessidade (material, transporte, espaço, apoio…) e a Praça. A assessoria + revisa e responde por aqui. +

+
+ + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/demandas/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/page.tsx new file mode 100644 index 00000000..9bfde9da --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/demandas/page.tsx @@ -0,0 +1,191 @@ +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 { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table' +import { + campaignDemandKindLabels, + campaignDemandStatusLabels, + campaignDemandStatuses, + type CampaignDemandStatus, +} from '@/lib/schemas/campaignDemand' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadDemandListPageData, parseDemandListParams } from '@/utilities/campaignDemandData' + +type DemandsPageProps = { + searchParams: Promise> +} + +const statusVariant: Record< + CampaignDemandStatus, + 'secondary' | 'estimate-pending' | 'destructive' | 'estimate-confirmed' +> = { + aberta: 'estimate-pending', + em_analise: 'secondary', + escalada: 'estimate-pending', + aprovada: 'estimate-confirmed', + rejeitada: 'destructive', +} + +const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }) + +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') + + const state = parseDemandListParams(rawSearchParams) + const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData( + payload, + user, + state, + ) + + const hrefFor = (status?: CampaignDemandStatus, page = 1) => { + const params = new URLSearchParams() + if (status) params.set('status', status) + if (state.kind) params.set('kind', state.kind) + if (page > 1) params.set('page', String(page)) + const query = params.toString() + return query ? `/campanha/demandas?${query}` : '/campanha/demandas' + } + + return ( + +
+
+

Demandas

+

+ Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando + preciso — decididas pelo Coordenador Geral. +

+ + {openCount} {openCount === 1 ? 'demanda em aberto' : 'demandas em aberto'} + +
+ +
+ + + + {rows.length ? ( + <> +
+ + + + Demanda + Tipo + Praça + Solicitante + Status + Aberta em + + + + {rows.map((row) => ( + + + + {row.title} + + + {campaignDemandKindLabels[row.kind]} + {row.plazaName} + + {row.requesterName ?? '—'} + + + + {campaignDemandStatusLabels[row.status]} + + + {dateFormatter.format(new Date(row.createdAt))} + + ))} + +
+
+
+

+ {totalDocs} {totalDocs === 1 ? 'demanda' : 'demandas'} +

+ hrefFor(state.status, page)} + /> +
+ + ) : ( + + + + + Nenhuma demanda por aqui + + Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma + ação. + + + + + + + )} +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts new file mode 100644 index 00000000..ab027fdf --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts @@ -0,0 +1,55 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +import { updateLeadershipInternal } from '@/app/(campaign)/campanha/actions/leadership' +import { + nullableFormText, + optionalFormText, + repeatedRelationshipFormValues, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +const safeMessages = [ + 'Você só pode vincular lideranças às Praças que assessora.', + 'Somente a coordenação e a assessoria podem gerenciar lideranças.', +] as const + +export const updateLeadershipInternalFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + try { + const sector = optionalFormText(formData, 'sector') + const supportStatus = optionalFormText(formData, 'supportStatus') + + await updateLeadershipInternal({ + id: requiredRelationshipFormValue(formData, 'leadershipId'), + plazas: repeatedRelationshipFormValues(formData, 'plazas'), + organizations: repeatedRelationshipFormValues(formData, 'organizations'), + sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number]) + ? (sector as (typeof leadershipSectors)[number]) + : null, + supportStatus: leadershipSupportStatuses.includes( + supportStatus as (typeof leadershipSupportStatuses)[number], + ) + ? (supportStatus as (typeof leadershipSupportStatuses)[number]) + : undefined, + notes: nullableFormText(formData, 'notes'), + consentNote: nullableFormText(formData, 'consentNote'), + }) + revalidatePath('/campanha/liderancas/[id]', 'page') + return { status: 'success', message: 'Ficha da liderança atualizada.' } + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages, + genericMessage: 'Não foi possível salvar a ficha. Verifique seu acesso e tente novamente.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx new file mode 100644 index 00000000..71259b35 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -0,0 +1,94 @@ +import config from '@payload-config' +import { ArrowLeftIcon } from 'lucide-react' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' +import { getPayload } from 'payload' + +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { LeadershipInternalForm } from '@/components/campaign/LeadershipInternalForm' +import { LeadershipInviteButtons } from '@/components/campaign/LeadershipInviteButtons' +import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions' +import { loadLeadershipDetail } from '@/utilities/leadershipData' +import { updateLeadershipInternalFormAction } from './formActions' + +type LeadershipDetailPageProps = { + params: Promise<{ id: string }> +} + +export default async function LeadershipDetailPage({ params }: LeadershipDetailPageProps) { + 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 leadership = await loadLeadershipDetail(payload, user, Number(id)) + if (!leadership) notFound() + + const [plazaOptions, organizationOptions] = await Promise.all([ + loadPlazaOptions(payload, user), + loadOrganizationOptions(payload, user), + ]) + + return ( + +
+ +
+

{leadership.name}

+ {leadership.supportStatus ? ( + + ) : null} + + {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} + +
+

+ {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} + {leadership.email ? ` · ${leadership.email}` : ''} +

+
+ +
+
+

+ Convites pelo WhatsApp +

+

+ O convite de acesso ao app exige status engajado. Links são de uso único. +

+
+ +
+ +
+

+ Ficha interna +

+ +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts new file mode 100644 index 00000000..119962e1 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -0,0 +1,57 @@ +'use server' + +import { redirect } from 'next/navigation' + +import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership' +import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData' +import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +const safeMessages = [ + 'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novas Praças.', + 'Você só pode vincular lideranças às Praças que assessora.', + 'Somente a coordenação e a assessoria podem gerenciar lideranças.', + 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', +] as const + +export const createLeadershipFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + let createdId: number | null = null + try { + const sector = optionalFormText(formData, 'sector') + const supportStatus = optionalFormText(formData, 'supportStatus') + + const leadership = await createLeadership({ + name: requiredFormText(formData, 'name'), + phone: requiredFormText(formData, 'phone'), + email: optionalFormText(formData, 'email'), + plazas: repeatedRelationshipFormValues(formData, 'plazas'), + organizations: repeatedRelationshipFormValues(formData, 'organizations'), + sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number]) + ? (sector as (typeof leadershipSectors)[number]) + : undefined, + supportStatus: leadershipSupportStatuses.includes( + supportStatus as (typeof leadershipSupportStatuses)[number], + ) + ? (supportStatus as (typeof leadershipSupportStatuses)[number]) + : 'a_abordar', + notes: optionalFormText(formData, 'notes'), + consentNote: optionalFormText(formData, 'consentNote'), + }) + createdId = leadership.id + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages, + genericMessage: + 'Não foi possível cadastrar a liderança. Verifique os dados e tente novamente.', + }) + } + + redirect(`/campanha/liderancas/${createdId}`) +} diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx new file mode 100644 index 00000000..6ff57027 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx @@ -0,0 +1,64 @@ +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 { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { LeadershipForm } from '@/components/campaign/LeadershipForm' +import { Button } from '@/components/ui/button' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions' +import { createLeadershipFormAction } from './formActions' + +type NewLeadershipPageProps = { + searchParams: Promise> +} + +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 [plazaOptions, organizationOptions] = await Promise.all([ + loadPlazaOptions(payload, user), + loadOrganizationOptions(payload, user), + ]) + + const rawInitialPlaza = Array.isArray(rawSearchParams.plaza) + ? rawSearchParams.plaza[0] + : rawSearchParams.plaza + const initialPlazaID = + rawInitialPlaza && /^[1-9]\d*$/.test(rawInitialPlaza) ? Number(rawInitialPlaza) : null + const initialPlazaIDs = + initialPlazaID && plazaOptions.some((option) => option.id === initialPlazaID) + ? [initialPlazaID] + : [] + + return ( + +
+ +

Nova liderança

+

+ O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de + inserir dados reais. +

+
+ + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx new file mode 100644 index 00000000..415adf92 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -0,0 +1,162 @@ +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 { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadLeadershipListPageData, parseLeadershipListParams } from '@/utilities/leadershipData' + +type LeadershipsPageProps = { + searchParams: Promise> +} + +export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) { + const rawSearchParams = await searchParams + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + if (!user) redirect('/campanha/login') + if (!isCampaignStaff(user)) redirect('/campanha') + + const state = parseLeadershipListParams(rawSearchParams) + const { rows, totalDocs, totalPages } = await loadLeadershipListPageData(payload, user, state) + + const hrefForPage = (page: number) => { + const params = new URLSearchParams() + if (state.q) params.set('q', state.q) + if (page > 1) params.set('page', String(page)) + const query = params.toString() + return query ? `/campanha/liderancas?${query}` : '/campanha/liderancas' + } + + return ( + +
+
+

Lideranças

+

+ Uma ficha por pessoa — cada liderança pode atuar em várias Praças e organizações. +

+
+ +
+ +
+ + +
+ + {rows.length ? ( + <> +
+ + + + Nome + Status + Praças + Organizações + Acesso ao app + + + + {rows.map((row) => ( + + + + {row.name} + + + + {row.supportStatus ? : '—'} + + + {row.plazaNames.join(', ') || '—'} + + + {row.organizationNames.join(', ') || '—'} + + + + {row.hasAppAccess ? 'Com acesso' : 'Sem acesso'} + + + + ))} + +
+
+
+

+ {totalDocs} {totalDocs === 1 ? 'liderança' : 'lideranças'} +

+ +
+ + ) : ( + + + + + Nenhuma liderança encontrada + + Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas + Praças. + + + + + + + )} +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts deleted file mode 100644 index 33cd17d2..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts +++ /dev/null @@ -1,85 +0,0 @@ -'use server' - -import config from '@payload-config' -import { getPayload } from 'payload' -import { revalidatePath } from 'next/cache' - -import { assignNucleusCoordinators } from '@/app/(campaign)/campanha/actions/coordinatorAssignment' -import { repeatedRelationshipFormValues } from '@/lib/formData' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' -import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' - -export type CoordinatorAssignmentFormState = { - status?: 'success' - message?: string - fieldErrors?: Record -} - -export type CoordinatorAssignmentOptionsResult = { - expectedUpdatedAt: string - options: Array<{ id: number; name: string; isCurrent: boolean }> -} - -const knownMessages = [ - 'Somente a coordenação geral pode alterar coordenadores.', - 'Núcleo não encontrado ou sem acesso.', - 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.', - 'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.', -] as const - -const errorState = (error: unknown): CoordinatorAssignmentFormState => - mapCampaignFormActionError({ - error, - safeMessages: knownMessages, - genericMessage: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.', - }) - -export const assignNucleusCoordinatorsFormAction = async ( - slug: string, - _state: CoordinatorAssignmentFormState, - formData: FormData, -): Promise => { - try { - const expectedUpdatedAt = formData.get('expectedUpdatedAt') - if (typeof expectedUpdatedAt !== 'string' || !expectedUpdatedAt) { - return { message: 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.' } - } - await assignNucleusCoordinators({ - slug, - expectedUpdatedAt, - coordinatorIds: repeatedRelationshipFormValues(formData, 'coordinators'), - }) - revalidatePath(`/campanha/nucleos/${slug}`) - revalidatePath('/campanha') - return { status: 'success', message: 'Coordenação atualizada.' } - } catch (error) { - return errorState(error) - } -} - -export const loadCoordinatorAssignmentOptions = async ( - slug: string, -): Promise => { - const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) - if (!user || user.role !== 'geral') { - throw new Error('Somente a coordenação geral pode alterar coordenadores.') - } - - const result = await payload.find({ - collection: 'electoralNucleus', - where: { slug: { equals: slug } }, - depth: 0, - limit: 1, - select: { updatedAt: true }, - user, - overrideAccess: false, - }) - const nucleus = result.docs[0] - if (!nucleus) throw new Error('Núcleo não encontrado ou sem acesso.') - - return { - expectedUpdatedAt: nucleus.updatedAt, - options: await getEligibleNucleusCoordinatorOptions(payload, user), - } -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx deleted file mode 100644 index b64399d1..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import config from '@payload-config' -import { getPayload } from 'payload' -import { notFound, redirect } from 'next/navigation' - -import { updateNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions' -import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' -import { NucleusForm } from '@/components/campaign/NucleusForm' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { getNucleusEditPageData } from '@/utilities/nucleusPageData' - -type EditNucleusPageProps = { - params: Promise<{ slug: string }> -} - -export default async function EditNucleusPage({ params }: EditNucleusPageProps) { - const [{ slug }, user, payload] = await Promise.all([ - params, - getCampaignUser(), - getPayload({ config }), - ]) - - if (!user) redirect('/campanha/login') - if (user.role === 'lideranca') redirect('/campanha/nucleos') - - if (!slug) notFound() - const view = await getNucleusEditPageData(payload, user, slug).catch(() => notFound()) - - return ( -
-
- - {user.role === 'geral' - ? 'Edição · Coordenação geral' - : 'Edição · Núcleo sob sua coordenação'} - -

Núcleos Eleitorais

-

Editar {view.name}

-

- Atualize o território sem alterar o nome ou as estimativas confirmadas. -

-
- -
- ) -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts deleted file mode 100644 index 9386038d..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions.ts +++ /dev/null @@ -1,154 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' - -import { - createLeadership, - updateLeadershipInternal, -} from '@/app/(campaign)/campanha/actions/leadership' -import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus' -import { leadershipInternalUpdateSchema } from '@/lib/schemas/leadership' -import { - FormDataBoundaryError, - nullableFormText, - optionalFormText, - requiredRelationshipFormValue, -} from '@/lib/formData' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' - -export type LeadershipFormState = { - status?: 'success' - message?: string - fieldErrors?: Record - leadershipId?: number - contactReused?: boolean - values?: LeadershipFormValues - revision?: number -} - -export type LeadershipFormValues = { - name?: string - phone?: string - email?: string - gender?: string - sector?: string - sectorNotes?: string - supportStatus?: string - notes?: string - consentNote?: string -} - -const safeActionMessages = [ - 'Esta pessoa já está cadastrada como liderança neste núcleo.', - 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', - 'Escolha outro contato principal antes de alterar o status desta liderança.', - 'Somente a coordenação pode gerenciar lideranças.', -] as const - -const resolveLeadershipBoundaryMessage = (error: FormDataBoundaryError): string => - error.field === 'contact' - ? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.' - : error.field === 'id' || error.field === 'nucleus' - ? 'Não foi possível identificar o registro. Atualize a página e tente novamente.' - : error.message - -const getLeadershipFormError = ( - error: unknown, - values?: LeadershipFormValues, - revision?: number, -): LeadershipFormState => - mapCampaignFormActionError({ - error, - safeMessages: safeActionMessages, - genericMessage: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.', - values, - revision, - resolveBoundaryMessage: resolveLeadershipBoundaryMessage, - }) - -export const createLeadershipFormAction = async ( - state: LeadershipFormState, - formData: FormData, -): Promise => { - let values: LeadershipFormValues | undefined - const revision = (state.revision ?? 0) + 1 - - try { - values = { - name: optionalFormText(formData, 'name'), - phone: optionalFormText(formData, 'phone'), - email: optionalFormText(formData, 'email'), - gender: optionalFormText(formData, 'gender'), - sector: optionalFormText(formData, 'sector'), - sectorNotes: optionalFormText(formData, 'sectorNotes'), - supportStatus: optionalFormText(formData, 'supportStatus'), - notes: optionalFormText(formData, 'notes'), - consentNote: optionalFormText(formData, 'consentNote'), - } - const input = { - nucleus: requiredRelationshipFormValue(formData, 'nucleus'), - ...values, - name: values.name ?? '', - phone: values.phone ?? '', - } - const leadership = await createLeadership(input) - revalidatePath('/campanha/nucleos/[slug]', 'page') - - return { - status: 'success', - message: leadership.contactReused - ? 'Liderança cadastrada. O contato existente com este celular foi reutilizado.' - : 'Liderança cadastrada com sucesso.', - leadershipId: leadership.id, - contactReused: leadership.contactReused, - } - } catch (error) { - return getLeadershipFormError(error, values, revision) - } -} - -export const updateLeadershipFormAction = async ( - _state: LeadershipFormState, - formData: FormData, -): Promise => { - try { - const input = leadershipInternalUpdateSchema.parse({ - id: requiredRelationshipFormValue(formData, 'id'), - sector: nullableFormText(formData, 'sector'), - sectorNotes: nullableFormText(formData, 'sectorNotes'), - supportStatus: optionalFormText(formData, 'supportStatus'), - notes: nullableFormText(formData, 'notes'), - consentNote: nullableFormText(formData, 'consentNote'), - }) - const leadership = await updateLeadershipInternal(input) - revalidatePath('/campanha/nucleos/[slug]', 'page') - - return { - status: 'success', - message: 'Liderança atualizada com sucesso.', - leadershipId: leadership.id, - } - } catch (error) { - return getLeadershipFormError(error) - } -} - -export const setPrimaryContactFormAction = async ( - _state: LeadershipFormState, - formData: FormData, -): Promise => { - try { - await updateNucleus({ - id: requiredRelationshipFormValue(formData, 'nucleus'), - primaryContact: requiredRelationshipFormValue(formData, 'contact'), - }) - revalidatePath('/campanha/nucleos/[slug]', 'page') - - return { - status: 'success', - message: 'Contato principal atualizado.', - } - } catch (error) { - return getLeadershipFormError(error) - } -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/not-found.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/not-found.tsx deleted file mode 100644 index c4b02bd4..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/not-found.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Link from 'next/link' -import { MapPinOffIcon } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/Empty' - -export default function NucleusNotFound() { - return ( - - - - - Núcleo não encontrado - - Ele pode não existir ou estar fora do seu escopo de acesso. - - - - - - - ) -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIbgeVoterProfileFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIbgeVoterProfileFormActions.ts deleted file mode 100644 index 5bcb2b40..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIbgeVoterProfileFormActions.ts +++ /dev/null @@ -1,95 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' - -import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus' -import { requiredRelationshipFormValue } from '@/lib/formData' -import { getCampaignActionContext } from '@/utilities/campaignActionContext' -import { - type CampaignFormActionState, - mapCampaignFormActionError, -} from '@/utilities/campaignFormActionError' -import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography' -import { - getNucleusIbgeVoterProfile, - IBGE_VOTER_PROFILE_LABEL, -} from '@/utilities/nucleusIbgeVoterProfile' -import { voterProfileNullsToUndefined } from '@/utilities/nucleusIntelligenceUi' - -export type AppendIbgeVoterProfileFormState = CampaignFormActionState - -const safeMessages = [ - 'Somente a coordenação pode editar a inteligência do núcleo.', - 'Não há perfil IBGE disponível para este território.', - 'Já existe um perfil manual com este rótulo.', -] as const - -const errorState = (error: unknown): AppendIbgeVoterProfileFormState => - mapCampaignFormActionError({ - error, - safeMessages, - genericMessage: 'Não foi possível adicionar o perfil IBGE. Tente novamente.', - }) - -export const appendIbgeVoterProfileFormAction = async ( - _state: AppendIbgeVoterProfileFormState, - formData: FormData, -): Promise => { - try { - const { payload, actor } = await getCampaignActionContext() - if (actor.role === 'lideranca') { - throw new Error('Somente a coordenação pode editar a inteligência do núcleo.') - } - - const nucleusId = requiredRelationshipFormValue(formData, 'nucleus') - const nucleus = await payload.findByID({ - collection: 'electoralNucleus', - id: nucleusId, - depth: 0, - user: actor, - overrideAccess: false, - select: { - slug: true, - cities: true, - regions: true, - voterProfiles: { - label: true, - ageRange: true, - incomeBand: true, - occupation: true, - localTraits: true, - notes: true, - }, - }, - }) - - const computed = getNucleusIbgeVoterProfile(toNucleusElectionGeographyInput(nucleus)) - - if (computed.status !== 'available') { - throw new Error('Não há perfil IBGE disponível para este território.') - } - - const existingProfiles = (nucleus.voterProfiles ?? []).map(voterProfileNullsToUndefined) - if (existingProfiles.some((profile) => profile.label === IBGE_VOTER_PROFILE_LABEL)) { - throw new Error('Já existe um perfil manual com este rótulo.') - } - - const updated = await updateNucleus({ - id: nucleusId, - voterProfiles: [ - ...existingProfiles, - { - label: computed.profile.label, - ageRange: computed.profile.ageRange, - localTraits: computed.profile.localTraits, - notes: computed.profile.notes, - }, - ], - }) - - revalidatePath(`/campanha/nucleos/${updated.slug}`) - return { status: 'success', message: 'Perfil IBGE adicionado aos perfis manuais.' } - } catch (error) { - return errorState(error) - } -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts deleted file mode 100644 index 414d52b2..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts +++ /dev/null @@ -1,31 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' - -import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' -import { parseNucleusIntelligenceFormData } from '@/utilities/nucleusIntelligenceUi' - -export type NucleusIntelligenceFormState = { - status?: 'success' - message?: string - fieldErrors?: Record -} - -export const updateNucleusIntelligenceFormAction = async ( - _state: NucleusIntelligenceFormState, - formData: FormData, -): Promise => { - try { - const input = parseNucleusIntelligenceFormData(formData) - await updateNucleus(input) - revalidatePath('/campanha/nucleos/[slug]', 'page') - return { status: 'success', message: 'Inteligência do núcleo atualizada.' } - } catch (error) { - return mapCampaignFormActionError({ - error, - genericMessage: - 'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.', - }) - } -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts deleted file mode 100644 index 3347594e..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts +++ /dev/null @@ -1,62 +0,0 @@ -'use server' - -import config from '@payload-config' -import { getPayload, type Payload } from 'payload' -import { revalidatePath } from 'next/cache' - -import { createNucleusUpdateRecord } from '@/app/(campaign)/campanha/actions/nucleusUpdate' -import type { CampaignUser } from '@/payload-types' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' -import { parseNucleusUpdateFormData } from '@/utilities/nucleusUpdateUi' -import { requireRelationshipId } from '@/utilities/relationship' - -export type NucleusUpdateFormState = { - status?: 'success' - message?: string - fieldErrors?: Record -} - -export const createNucleusUpdateFormRecordAction = async ( - payload: Payload, - actor: CampaignUser, - formData: FormData, -): Promise => { - try { - const input = parseNucleusUpdateFormData(formData) - const created = await createNucleusUpdateRecord(payload, actor, input) - return { - status: 'success', - message: 'Atualização enviada com sucesso.', - nucleusId: requireRelationshipId(created.nucleus), - } - } catch (error) { - return mapCampaignFormActionError({ - error, - genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', - }) - } -} - -export const createNucleusUpdateFormAction = async ( - _state: NucleusUpdateFormState, - formData: FormData, -): Promise => { - const [payload, actor] = await Promise.all([getPayload({ config }), getCampaignUser()]) - if (!actor) { - return { - message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', - } - } - - const { nucleusId, ...state } = await createNucleusUpdateFormRecordAction( - payload, - actor, - formData, - ) - if (state.status === 'success' && nucleusId) { - revalidatePath('/campanha') - revalidatePath('/campanha/nucleos/[slug]', 'page') - } - return state -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx deleted file mode 100644 index 8c91551e..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import config from '@payload-config' -import { PencilIcon, PlusIcon } from 'lucide-react' -import Link from 'next/link' -import { notFound, redirect } from 'next/navigation' -import { getPayload } from 'payload' -import { Suspense } from 'react' - -import { - assignNucleusCoordinatorsFormAction, - loadCoordinatorAssignmentOptions, -} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions' -import { loadNucleusShareRecipients } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions' -import { confirmVoteEstimateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions' -import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog' -import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' -import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssignmentCard' -import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog' -import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab' -import { NucleusTabNav } from '@/components/campaign/NucleusTabNav' -import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker' -import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell' -import { TseZoneBadge } from '@/components/campaign/TseZoneBadge' -import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard' -import { VoteEstimateDialogShell } from '@/components/campaign/VoteEstimateDialogShell' -import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { - buildLeadershipPanelHref, - nucleusDetailFocusFallbackId, - parseLeadershipFilterState, -} from '@/utilities/leadershipUi' -import { loadNucleusDetailPageData } from '@/utilities/nucleusDetailPageData' -import { - getNucleusDetailTabRedirect, - resolveNucleusDetailTab, -} from '@/utilities/nucleusDetailTabUi' -import { NucleusNotFoundError } from '@/utilities/nucleusPageData' -import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi' - -type NucleusDetailPageProps = { - params: Promise<{ slug: string }> - searchParams: Promise> -} - -const detailScopeLabels = { - geral: 'Visível para você · Coordenação geral', - coordenador: 'Visível para você · Coordenador', - lideranca: 'Visível para você · Liderança', -} as const - -export default async function NucleusDetailPage({ params, searchParams }: NucleusDetailPageProps) { - const [{ slug }, query, user, payload] = await Promise.all([ - params, - searchParams, - getCampaignUser(), - getPayload({ config }), - ]) - - if (!user) redirect('/campanha/login') - - if (!slug) notFound() - const nucleusKind = user.role === 'lideranca' ? 'leadership' : 'staff' - const activeTab = resolveNucleusDetailTab(query, nucleusKind) - const { context, view, coordinatorAssignment } = await loadNucleusDetailPageData( - payload, - user, - slug, - activeTab, - ).catch((error) => { - if (error instanceof NucleusNotFoundError) notFound() - throw error - }) - const canonicalTabRedirect = getNucleusDetailTabRedirect(view.slug, query, view.kind) - if (canonicalTabRedirect) redirect(canonicalTabRedirect) - - const leadershipFilters = parseLeadershipFilterState(query) - const canEdit = user.role === 'geral' || user.role === 'coordenador' - const isStaffView = view.kind === 'staff' - const confirmVoteEstimateAction = confirmVoteEstimateFormAction.bind( - null, - isStaffView ? view.proposedVoteEstimateVersion : null, - ) - const territory = formatNucleusTerritoryLabel(view) - const voteEstimateFocusTargetId = `vote-estimate-${view.id}-focus-target` - - return ( -
-
- {detailScopeLabels[user.role]} -
-
-
-

- {view.name} -

- {view.status === 'arquivado' ? Arquivado : null} -
- - {view.organizationLabel ?? organizationKindLabels[view.organizationKind]} - {' · '} - {territory} - -
- {view.tseZones.length ? ( - view.tseZones.map((zoneNumber) => ( - - )) - ) : ( - Sem Zona TSE - )} -
-
-
- - {canEdit ? ( - - ) : null} - {user.role !== 'lideranca' ? ( - - ) : null} - - {user.role === 'geral' && view.status === 'ativo' ? ( - - ) : null} -
-
-
- -
- - {coordinatorAssignment.canManage ? ( - - ) : null} - - - - } - /> -
- - - }> - - - -
- ) -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions.ts deleted file mode 100644 index fdc2e0da..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions.ts +++ /dev/null @@ -1,29 +0,0 @@ -'use server' - -import config from '@payload-config' -import { getPayload } from 'payload' - -import { getCampaignUser } from '@/utilities/campaignAuth' -import { resolveAccessibleNucleusContext } from '@/utilities/nucleusPageData' -import { - searchNucleusPrimaryContactOptions, - type NucleusPrimaryContactPageData, -} from '@/utilities/primaryContactPageData' - -export const searchPrimaryContactOptionsFormAction = async ( - nucleusSlug: string, - query: string, -): Promise => { - const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) - if (!user || user.role === 'lideranca') { - throw new Error('Somente a coordenação pode buscar contatos principais.') - } - - const context = await resolveAccessibleNucleusContext(payload, user, nucleusSlug) - return searchNucleusPrimaryContactOptions( - payload, - user, - context, - typeof query === 'string' ? query : '', - ) -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions.ts deleted file mode 100644 index 541c8eef..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions.ts +++ /dev/null @@ -1,40 +0,0 @@ -'use server' - -import config from '@payload-config' -import { headers } from 'next/headers' -import { getPayload } from 'payload' - -import { getCampaignUser } from '@/utilities/campaignAuth' -import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin' -import { - getNucleusShareRecipients, - type NucleusShareRecipients, -} from '@/utilities/nucleusShareRecipients' - -export type NucleusShareRecipientsResult = { - recipients: NucleusShareRecipients - nucleusUrl: string -} - -export const loadNucleusShareRecipients = async ( - slug: string, -): Promise => { - const [payload, user, requestHeaders] = await Promise.all([ - getPayload({ config }), - getCampaignUser(), - headers(), - ]) - if (!user) throw new Error('Faça login para compartilhar este núcleo.') - - const recipients = await getNucleusShareRecipients(payload, user, slug) - const baseURL = getCampaignInviteBaseURL({ - requestOrigin: requestHeaders.get('origin'), - forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'), - forwardedProto: requestHeaders.get('x-forwarded-proto'), - }) - - return { - recipients, - nucleusUrl: `${baseURL}/campanha/nucleos/${slug}`, - } -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts deleted file mode 100644 index 36432c35..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts +++ /dev/null @@ -1,73 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' - -import { - confirmVoteEstimate, - suggestVoteEstimate, -} from '@/app/(campaign)/campanha/actions/voteEstimate' -import { - optionalFormText, - requiredIntegerFormValue, - requiredRelationshipFormValue, -} from '@/lib/formData' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' - -export type VoteEstimateFormState = { - status?: 'success' - message?: string - fieldErrors?: Record -} - -const safeMessages = [ - 'A liderança precisa de vínculo engajado com este núcleo.', - 'Somente a coordenação pode confirmar estimativas.', - 'A sugestão foi alterada. Atualize a página antes de confirmar.', - 'Informe uma justificativa para ajustar a estimativa.', -] as const - -const errorState = (error: unknown): VoteEstimateFormState => - mapCampaignFormActionError({ - error, - safeMessages, - genericMessage: 'Não foi possível salvar a estimativa. Tente novamente.', - }) - -export const suggestVoteEstimateFormAction = async ( - _state: VoteEstimateFormState, - formData: FormData, -): Promise => { - try { - const updated = await suggestVoteEstimate({ - nucleus: requiredRelationshipFormValue(formData, 'nucleus'), - estimate: requiredIntegerFormValue(formData, 'estimate', { - maximum: 100_000_000, - }), - }) - revalidatePath(`/campanha/nucleos/${updated.slug}`) - return { status: 'success', message: 'Sugestão enviada para revisão.' } - } catch (error) { - return errorState(error) - } -} - -export const confirmVoteEstimateFormAction = async ( - expectedProposedVoteEstimateVersion: string | null, - _state: VoteEstimateFormState, - formData: FormData, -): Promise => { - try { - const updated = await confirmVoteEstimate({ - nucleus: requiredRelationshipFormValue(formData, 'nucleus'), - estimate: requiredIntegerFormValue(formData, 'estimate', { - maximum: 100_000_000, - }), - expectedProposedVoteEstimateVersion, - confirmationNote: optionalFormText(formData, 'confirmationNote'), - }) - revalidatePath(`/campanha/nucleos/${updated.slug}`) - return { status: 'success', message: 'Estimativa confirmada.' } - } catch (error) { - return errorState(error) - } -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts deleted file mode 100644 index 243732e9..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts +++ /dev/null @@ -1,111 +0,0 @@ -'use server' - -import config from '@payload-config' -import { getPayload } from 'payload' -import { redirect } from 'next/navigation' - -import { - archiveNucleus, - createNucleus, - updateNucleus, -} from '@/app/(campaign)/campanha/actions/nucleus' -import type { NucleusFormState } from '@/components/campaign/NucleusForm' -import { optionalFormText, requiredRelationshipFormValue } from '@/lib/formData' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' -import { - parseNucleusCreateFormData, - parseNucleusUpdateFormData, -} from '@/utilities/nucleusFormData' -import { slugify } from '@/utilities/slug' - -const getActionError = (error: unknown): NucleusFormState => - mapCampaignFormActionError({ - error, - genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.', - }) - -const existingNucleusState = async (formData: FormData): Promise => { - const name = optionalFormText(formData, 'name') - const slug = name ? slugify(name) : '' - if (!slug) return null - const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) - if (!user || user.role !== 'geral') return null - const result = await payload.find({ - collection: 'electoralNucleus', - where: { slug: { equals: slug } }, - depth: 0, - limit: 1, - pagination: false, - select: { slug: true }, - user, - overrideAccess: false, - }) - const existing = result.docs[0] - return existing - ? { - fieldErrors: { name: ['Já existe um núcleo com este nome.'] }, - existingHref: `/campanha/nucleos/${existing.slug}`, - submittedName: name, - } - : null -} - -export const createNucleusFormAction = async ( - _state: NucleusFormState, - formData: FormData, -): Promise => { - let nucleusSlug: string - - try { - const nucleus = await createNucleus(parseNucleusCreateFormData(formData)) - nucleusSlug = nucleus.slug - } catch (error) { - const existing = await existingNucleusState(formData) - if (existing) return existing - return getActionError(error) - } - - redirect(`/campanha/nucleos/${nucleusSlug}`) -} - -export const updateNucleusFormAction = async ( - _state: NucleusFormState, - formData: FormData, -): Promise => { - let nucleusSlug: string - - try { - const input = parseNucleusUpdateFormData(formData) - const nucleus = await updateNucleus(input) - nucleusSlug = nucleus.slug - } catch (error) { - return getActionError(error) - } - - redirect(`/campanha/nucleos/${nucleusSlug}`) -} - -export const archiveNucleusFormAction = async ( - _state: NucleusFormState, - formData: FormData, -): Promise => { - let nucleusId: number - try { - nucleusId = requiredRelationshipFormValue(formData, 'id') - } catch { - return { - message: 'Não foi possível identificar o núcleo. Atualize a página e tente novamente.', - } - } - - try { - await archiveNucleus(nucleusId) - } catch { - return { - message: - 'Não foi possível arquivar este núcleo. Verifique seu acesso ou atualize a página e tente novamente.', - } - } - redirect('/campanha/nucleos') -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx b/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx deleted file mode 100644 index 8c2aeb3b..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' -import { Skeleton } from '@/components/ui/skeleton' - -export default function NucleiLoading() { - return ( - -
- - -
- - - -
- ) -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx deleted file mode 100644 index d86dc7d3..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import config from '@payload-config' -import { getPayload } from 'payload' -import { redirect } from 'next/navigation' - -import { createNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions' -import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' -import { NucleusForm } from '@/components/campaign/NucleusForm' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' - -export default async function NewNucleusPage() { - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - - if (!user) redirect('/campanha/login') - if (user.role !== 'geral') redirect('/campanha/nucleos') - - const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user) - - return ( -
-
- Criação · Coordenação geral -

Núcleos Eleitorais

-

Novo núcleo

-

- Defina a operação, o território obrigatório e quem responde por ele. -

-
- -
- ) -} diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx deleted file mode 100644 index 531f8f61..00000000 --- a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import type { ElectoralNucleus } from '@/payload-types' -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 { CampaignListPagination } from '@/components/campaign/CampaignListPagination' -import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' -import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' -import { NucleusFilters } from '@/components/campaign/NucleusFilters' -import { NucleusList } from '@/components/campaign/NucleusList' -import { NucleusListOverview } from '@/components/campaign/NucleusListOverview' -import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker' -import { Button } from '@/components/ui/button' -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/Empty' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPageData' -import { loadNucleusListPageData } from '@/utilities/nucleusPageData' -import { - buildNucleusFiltersKey, - buildNucleusListHref, - buildNucleusListVisitHref, - buildNucleusListVisitLabel, - getCampaignScopeLabel, - resolveNucleusListUrl, -} from '@/utilities/nucleusUi' -import { toNucleusListViewModel } from '@/utilities/nucleusViewModels' - -type NucleiPageProps = { - searchParams: Promise> -} - -export default async function NucleiPage({ searchParams }: NucleiPageProps) { - const rawSearchParams = await searchParams - const canonicalUrl = resolveNucleusListUrl(rawSearchParams) - if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) - - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) - - if (!user) return null - - const now = new Date() - const [{ result, scope }, overview] = await Promise.all([ - loadNucleusListPageData(payload, user, rawSearchParams), - loadNucleusListOverviewData(payload, user, canonicalUrl.state, now), - ]) - const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages) - - if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) - const { state } = resolvedUrl - const listVisitLabel = buildNucleusListVisitLabel(state) - - return ( - -
-
-

Núcleos Eleitorais

-

- Núcleo é a operação da campanha; Zona TSE é a circunscrição oficial. -

- - {getCampaignScopeLabel(user.role, scope.totalDocs)} - -
- {user.role === 'geral' ? ( - - ) : null} -
- - - - {result.docs.length ? ( - <> - {overview ? : null} - - toNucleusListViewModel(nucleus as ElectoralNucleus), - )} - /> -
-

- {result.totalDocs}{' '} - {result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'} -

- buildNucleusListHref(state, page)} - /> -
- - ) : ( - - - - - Nenhum núcleo encontrado - - Ajuste a busca ou os filtros. Você só vê núcleos dentro do seu escopo. - - - - - - - )} - {listVisitLabel ? ( - - ) : null} -
- ) -} diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts new file mode 100644 index 00000000..f25d18fb --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/formActions.ts @@ -0,0 +1,42 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +import { updateOrganization } from '@/app/(campaign)/campanha/actions/organization' +import { + nullableFormText, + optionalFormText, + repeatedRelationshipFormValues, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +export const updateOrganizationFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + try { + const rawKind = optionalFormText(formData, 'kind') + await updateOrganization({ + id: requiredRelationshipFormValue(formData, 'organizationId'), + kind: organizationKinds.includes(rawKind as OrganizationKind) + ? (rawKind as OrganizationKind) + : undefined, + notes: nullableFormText(formData, 'notes'), + plazas: repeatedRelationshipFormValues(formData, 'plazas'), + }) + revalidatePath('/campanha/organizacoes/[slug]', 'page') + return { status: 'success', message: 'Organização atualizada.' } + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages: ['Somente a coordenação e a assessoria gerenciam organizações.'], + 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 new file mode 100644 index 00000000..9b638f97 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx @@ -0,0 +1,150 @@ +import config from '@payload-config' +import { ArrowLeftIcon, CalendarDaysIcon, HandshakeIcon } from 'lucide-react' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' +import { getPayload } from 'payload' + +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { OrganizationForm } from '@/components/campaign/OrganizationForm' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { actionPlanStatusLabels, actionPlanStatuses } from '@/lib/schemas/actionPlan' +import { organizationKindLabels } from '@/lib/schemas/organization' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadPlazaOptions } from '@/utilities/campaignRelationOptions' +import { loadOrganizationDetail } from '@/utilities/organizationData' +import { updateOrganizationFormAction } from './formActions' + +type OrganizationDetailPageProps = { + params: Promise<{ slug: string }> +} + +const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }) + +const statusLabel = (status: string): string => + actionPlanStatuses.includes(status as (typeof actionPlanStatuses)[number]) + ? actionPlanStatusLabels[status as (typeof actionPlanStatuses)[number]] + : status + +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 organization = await loadOrganizationDetail(payload, user, slug) + if (!organization) notFound() + + const plazaOptions = await loadPlazaOptions(payload, user) + + return ( + +
+ +
+

{organization.name}

+ {organizationKindLabels[organization.kind]} +
+ {organization.plazaNames.length ? ( +

Atua em {organization.plazaNames.join(', ')}

+ ) : null} + {organization.notes ? ( +

{organization.notes}

+ ) : null} +
+ +
+
+
+
+ {organization.leaderships.length ? ( +
    + {organization.leaderships.map((leadership) => ( +
  • + + {leadership.name} + +
  • + ))} +
+ ) : ( +

+ Nenhuma liderança vinculada. Vincule pela ficha da liderança. +

+ )} +
+ +
+
+
+ {organization.actionPlans.length ? ( +
    + {organization.actionPlans.map((plan) => ( +
  • +
    + + {plan.title} + + + {statusLabel(plan.status)} + {plan.startAt ? ` · ${dateFormatter.format(new Date(plan.startAt))}` : ''} + {plan.deputyPresent ? ' · Deputado presente' : ''} + +
    +
  • + ))} +
+ ) : ( +

+ Nenhum Plano de Ação vinculado a esta organização ainda. +

+ )} +
+
+ +
+

+ Editar organização +

+ +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts new file mode 100644 index 00000000..ecf49903 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/formActions.ts @@ -0,0 +1,42 @@ +'use server' + +import { redirect } from 'next/navigation' + +import { createOrganization } from '@/app/(campaign)/campanha/actions/organization' +import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData' +import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +export const createOrganizationFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + let createdSlug: string | null = null + try { + const rawKind = requiredFormText(formData, 'kind') + const organization = await createOrganization({ + name: requiredFormText(formData, 'name'), + kind: organizationKinds.includes(rawKind as OrganizationKind) + ? (rawKind as OrganizationKind) + : 'outro', + notes: optionalFormText(formData, 'notes'), + plazas: repeatedRelationshipFormValues(formData, 'plazas'), + }) + createdSlug = organization.slug + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages: [ + 'Já existe uma organização com este nome.', + 'Somente a coordenação e a assessoria gerenciam organizações.', + ], + genericMessage: + 'Não foi possível cadastrar a organização. Verifique os dados e tente novamente.', + }) + } + + redirect(`/campanha/organizacoes/${createdSlug}`) +} diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx new file mode 100644 index 00000000..f0ff9894 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx @@ -0,0 +1,41 @@ +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 { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { OrganizationForm } from '@/components/campaign/OrganizationForm' +import { Button } from '@/components/ui/button' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadPlazaOptions } 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 plazaOptions = await loadPlazaOptions(payload, user) + + return ( + +
+ +

Nova organização

+

+ Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e Planos de + Ação. +

+
+ + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx new file mode 100644 index 00000000..1cf652ae --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx @@ -0,0 +1,159 @@ +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 { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table' +import { organizationKindLabels } from '@/lib/schemas/organization' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { + loadOrganizationListPageData, + parseOrganizationListParams, +} from '@/utilities/organizationData' + +type OrganizationsPageProps = { + searchParams: Promise> +} + +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 state = parseOrganizationListParams(rawSearchParams) + const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state) + + const hrefForPage = (page: number) => { + const params = new URLSearchParams() + if (state.q) params.set('q', state.q) + if (state.kind) params.set('kind', state.kind) + if (page > 1) params.set('page', String(page)) + const query = params.toString() + return query ? `/campanha/organizacoes?${query}` : '/campanha/organizacoes' + } + + return ( + +
+
+

Organizações

+

+ Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e + Planos de Ação. +

+
+ +
+ +
+ + +
+ + {rows.length ? ( + <> +
+ + + + Nome + Tipo + Praças de atuação + Lideranças + + + + {rows.map((row) => ( + + + + {row.name} + + + + {organizationKindLabels[row.kind]} + + + {row.plazaNames.join(', ') || '—'} + + {row.leadershipCount} + + ))} + +
+
+
+

+ {totalDocs} {totalDocs === 1 ? 'organização' : 'organizações'} +

+ +
+ + ) : ( + + + + + Nenhuma organização cadastrada + + Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de + Ação. + + + + + + + )} +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index f41741b2..330bfc97 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -1,10 +1,10 @@ import config from '@payload-config' -import { getPayload } from 'payload' import { redirect } from 'next/navigation' +import { getPayload } from 'payload' import { CampaignDashboard } from '@/components/campaign/CampaignDashboard' -import { getCampaignDashboardPageData } from '@/utilities/campaignDashboardPageData' import { getCampaignUser } from '@/utilities/campaignAuth' +import { getCampaignDashboardData } from '@/utilities/campaignDashboardData' export const dynamic = 'force-dynamic' @@ -12,7 +12,6 @@ export default async function CampaignHomePage() { const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) if (!user) redirect('/campanha/login') - const now = new Date() - const view = await getCampaignDashboardPageData(payload, user, now) - return + const view = await getCampaignDashboardData(payload, user) + return } diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx index 569f01ca..84f9b0e1 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx @@ -8,12 +8,14 @@ import { } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions' import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions' import { ActionPlanForm } from '@/components/campaign/ActionPlanForm' +import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { ActionPlanNotFoundError, getActionPlanEditPageData, } from '@/utilities/actionPlanPageData' -import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' +import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions' +import { getEligibleAdvisorOptions } from '@/utilities/plazaViewModels' type EditActionPlanPageProps = { params: Promise<{ slug: string }> @@ -27,15 +29,18 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP ]) if (!user) redirect('/campanha/login') - if (user.role === 'lideranca') redirect('/campanha/planos') + if (!isCampaignStaff(user)) redirect('/campanha/planos') if (!slug) notFound() - const [view, coordinators] = await Promise.all([ + const canManageAdvisors = user.role === 'coordinator' + const [view, plazaOptions, organizationOptions, advisorOptions] = await Promise.all([ getActionPlanEditPageData(payload, user, slug).catch((error) => { if (error instanceof ActionPlanNotFoundError) notFound() throw error }), - getEligibleNucleusCoordinatorOptions(payload, user), + loadPlazaOptions(payload, user), + loadOrganizationOptions(payload, user), + canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]), ]) return ( @@ -50,8 +55,10 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP @@ -71,17 +73,18 @@ export default async function ActionPlanDetailPage({

{view.title}

+ {view.deputyPresent ? Deputado presente : null}
{actionPlanKindLabels[view.kind]} {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} - {view.territoryLabel ? · {view.territoryLabel} : null} + {view.locationLabel ? · {view.locationLabel} : null}
- {canEdit ? ( + {isStaff ? ( +

Editar {view.name}

+

+ A geografia da Praça é pré-definida e não pode ser alterada. Aqui você edita metas, + inteligência e tendência política. +

+ + + {user.role === 'coordinator' ? ( + + ) : null} + + + + ) +} diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx new file mode 100644 index 00000000..0b777dd6 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx @@ -0,0 +1,289 @@ +import config from '@payload-config' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' +import { getPayload } from 'payload' + +import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { CandidateComparePicker } from '@/components/campaign/CandidateComparePicker' +import { DeclareVotesForm } from '@/components/campaign/DeclareVotesForm' +import { PlazaBaselineCard } from '@/components/campaign/PlazaBaselineCard' +import { PlazaCandidateComparisonTable } from '@/components/campaign/PlazaCandidateComparisonTable' +import { PlazaLeadershipsPanel } from '@/components/campaign/PlazaLeadershipsPanel' +import { PlazaPledgesPanel } from '@/components/campaign/PlazaPledgesPanel' +import { PlazaStrategyCard } from '@/components/campaign/PlazaStrategyCard' +import { PlazaTabNav } from '@/components/campaign/PlazaTabNav' +import { PlazaUpdateFeed } from '@/components/campaign/PlazaUpdateFeed' +import { PlazaUpdateForm } from '@/components/campaign/PlazaUpdateForm' +import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker' +import { Badge } from '@/components/ui/Badge' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' +import { loadPlazaLeaderships } from '@/utilities/leadershipData' +import { + loadPlazaCandidateComparison, + MAX_COMPARISON_CANDIDATES, +} from '@/utilities/plazaCandidateComparison' +import { resolvePlazaDetailTab, type PlazaDetailSearchParams } from '@/utilities/plazaDetailTabUi' +import { plazaElectionGeographyForSlug } from '@/utilities/plazaElectionGeography' +import { loadPlazaElectoralBaseline } from '@/utilities/plazaElectoralBaseline' +import { + getPlazaDetailViewModel, + PlazaNotFoundError, + resolveAccessiblePlazaContext, +} from '@/utilities/plazaPageData' +import { formatPlazaGeographyLabel, plazaKindLabels } from '@/utilities/plazaUi' +import { loadPlazaUpdatesFeed, parsePlazaUpdateFeedParams } from '@/utilities/plazaUpdatePageData' +import { loadAdvisorSummaries } from '@/utilities/plazaViewModels' +import { loadLeaderPledges, loadPlazaPledges } from '@/utilities/votePledgeData' +import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions' +import { createPlazaUpdateFormAction } from './updateFormActions' + +type PlazaDetailPageProps = { + params: Promise<{ slug: string }> + searchParams: Promise +} + +const dateFormatter = new Intl.DateTimeFormat('pt-BR') + +export default async function PlazaDetailPage({ params, searchParams }: PlazaDetailPageProps) { + const [{ slug }, rawSearchParams] = await Promise.all([params, searchParams]) + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + if (!user) redirect('/campanha/login') + + let context + try { + context = await resolveAccessiblePlazaContext(payload, user, slug) + } catch (error) { + if (error instanceof PlazaNotFoundError) notFound() + throw error + } + + const isStaffView = isCampaignStaff(user) + const roleKind = isStaffView ? 'staff' : 'leader' + const activeTab = resolvePlazaDetailTab(rawSearchParams, roleKind) + const view = await getPlazaDetailViewModel(payload, context, user) + + const advisorSummaries = await loadAdvisorSummaries(payload, user, view.advisorIDs) + + return ( + +
+
+

{view.name}

+ {plazaKindLabels[view.kind]} +
+

{formatPlazaGeographyLabel(view)}

+

+ {advisorSummaries.length + ? `Assessoria: ${advisorSummaries.map((advisor) => advisor.name).join(', ')}` + : 'Sem assessor designado.'} + {view.lastUpdateAt + ? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}` + : ''} +

+
+ + + + {activeTab === 'overview' ? ( + + ) : null} + + {activeTab === 'elections' ? ( + + ) : null} + + {activeTab === 'leaderships' && isStaffView ? ( + + ) : null} + + {activeTab === 'updates' ? ( + + ) : null} + + {activeTab === 'demands' ? ( +
+

+ As demandas desta Praça aparecem em{' '} + + Demandas + + . +

+
+ ) : null} + + +
+ ) +} + +type PayloadUser = { + payload: Awaited> + user: NonNullable>> +} + +const OverviewTab = async ({ + view, + isStaffView, + payloadUser: { payload, user }, +}: { + view: Awaited> + isStaffView: boolean + payloadUser: PayloadUser +}) => { + if (isStaffView && view.strategy) { + const pledges = await loadPlazaPledges(payload, user, view.id) + return ( +
+ + +
+ ) + } + + const leaderPledges = await loadLeaderPledges(payload, user) + const ownPledge = leaderPledges.find((pledge) => pledge.plazaID === view.id) ?? null + + return ( +
+
+

Seus votos nesta Praça

+

+ Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser. +

+
+ +
+ ) +} + +const ElectionsTab = async ({ + slug, + rawSearchParams, + payloadUser: { payload, user }, +}: { + slug: string + rawSearchParams: PlazaDetailSearchParams + payloadUser: PayloadUser +}) => { + const geography = plazaElectionGeographyForSlug(slug) + const rawCompare = rawSearchParams.compare + const compareNumbers = [ + ...new Set( + (Array.isArray(rawCompare) ? rawCompare : rawCompare ? [rawCompare] : []) + .filter((value) => /^[1-9]\d*$/.test(value)) + .map(Number), + ), + ].slice(0, MAX_COMPARISON_CANDIDATES) + + const [baseline, comparisonRows, candidateOptions] = await Promise.all([ + geography ? loadPlazaElectoralBaseline(payload, user, geography) : null, + geography + ? loadPlazaCandidateComparison(payload, user, geography, compareNumbers) + : ([] as Awaited>), + loadFederalCandidateOptions(payload, user), + ]) + + return ( +
+ +
+
+

+ Comparativo entre candidatos +

+

+ Compare a votação de deputados federais nesta Praça através dos anos (fonte TSE, votos + nominais do 1º turno). +

+
+ + {comparisonRows.length ? : null} +
+
+ ) +} + +const LeadershipsTab = async ({ + plazaID, + payloadUser: { payload, user }, +}: { + plazaID: number + payloadUser: PayloadUser +}) => { + const [leaderships, pledges] = await Promise.all([ + loadPlazaLeaderships(payload, user, plazaID), + loadPlazaPledges(payload, user, plazaID), + ]) + + return ( + + ) +} + +const UpdatesTab = async ({ + plazaID, + plazaSlug, + rawSearchParams, + payloadUser: { payload, user }, +}: { + plazaID: number + plazaSlug: string + rawSearchParams: PlazaDetailSearchParams + payloadUser: PayloadUser +}) => { + const feedState = parsePlazaUpdateFeedParams(rawSearchParams) + const feed = await loadPlazaUpdatesFeed(payload, user, plazaID, feedState) + + return ( +
+ + + + `/campanha/pracas/${plazaSlug}?tab=updates${feedState.kind ? `&updateKind=${feedState.kind}` : ''}&updatePage=${page}` + } + /> +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts b/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts new file mode 100644 index 00000000..047e17ac --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts @@ -0,0 +1,76 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +import { declareVotes, estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge' +import { + nullableFormText, + nullableRelationshipFormValue, + optionalIntegerFormValue, + requiredIntegerFormValue, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +const declareSafeMessages = [ + 'Somente lideranças engajadas podem declarar votos.', + 'A liderança precisa estar vinculada à Praça para declarar votos nela.', + 'Informe a liderança da declaração.', +] as const + +export const declareVotesFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + try { + const plaza = requiredRelationshipFormValue(formData, 'plazaId') + const leadership = nullableRelationshipFormValue(formData, 'leadershipId') ?? undefined + const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', { + minimum: 0, + maximum: 1_000_000, + }) + + await declareVotes({ plaza, leadership, declaredVotes }) + revalidatePath('/campanha/pracas/[slug]', 'page') + return { status: 'success', message: 'Declaração de votos registrada.' } + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages: declareSafeMessages, + genericMessage: + 'Não foi possível registrar a declaração. Verifique seu acesso e tente novamente.', + }) + } +} + +export const estimateVotesFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + try { + const pledge = requiredRelationshipFormValue(formData, 'pledgeId') + const estimatedVotes = optionalIntegerFormValue(formData, 'estimatedVotes', { + minimum: 0, + maximum: 1_000_000, + }) + const estimateNote = nullableFormText(formData, 'estimateNote') + + await estimateVotes({ + pledge, + estimatedVotes: estimatedVotes ?? null, + estimateNote, + }) + revalidatePath('/campanha/pracas/[slug]', 'page') + return { status: 'success', message: 'Estimativa registrada.' } + } catch (error) { + return mapCampaignFormActionError({ + error, + safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'], + genericMessage: + 'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/pracas/[slug]/updateFormActions.ts new file mode 100644 index 00000000..e8a0aa8a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/updateFormActions.ts @@ -0,0 +1,48 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +import { createPlazaUpdate } from '@/app/(campaign)/campanha/actions/plazaUpdate' +import { + optionalFormText, + optionalIntegerFormValue, + requiredFormText, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { plazaUpdateKinds, type PlazaUpdateKind } from '@/lib/schemas/plazaUpdate' +import { + mapCampaignFormActionError, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +export const createPlazaUpdateFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => { + try { + const plaza = requiredRelationshipFormValue(formData, 'plazaId') + const rawKind = requiredFormText(formData, 'kind') + const kind = plazaUpdateKinds.includes(rawKind as PlazaUpdateKind) + ? (rawKind as PlazaUpdateKind) + : 'semanal' + + await createPlazaUpdate({ + plaza, + kind, + worked: optionalFormText(formData, 'worked'), + failed: optionalFormText(formData, 'failed'), + needs: optionalFormText(formData, 'needs'), + body: optionalFormText(formData, 'body'), + activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'), + newSupports: optionalIntegerFormValue(formData, 'newSupports'), + }) + revalidatePath('/campanha/pracas/[slug]', 'page') + return { status: 'success', message: 'Atualização registrada com sucesso.' } + } catch (error) { + return mapCampaignFormActionError({ + error, + genericMessage: + 'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/pracas/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/page.tsx new file mode 100644 index 00000000..95ce1f05 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/page.tsx @@ -0,0 +1,140 @@ +import config from '@payload-config' +import { SearchXIcon } from 'lucide-react' +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getPayload } from 'payload' + +import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { PlazaFilters } from '@/components/campaign/PlazaFilters' +import { PlazaList } from '@/components/campaign/PlazaList' +import { PlazaListOverview } from '@/components/campaign/PlazaListOverview' +import { PlazaMapPanelDynamic } from '@/components/campaign/PlazaMapPanelDynamic' +import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker' +import { Button } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' +import { loadPlazaMapBundle } from '@/utilities/plazaMapData' +import { loadPlazaListOverviewData, loadPlazaListPageData } from '@/utilities/plazaPageData' +import { + buildPlazaFiltersKey, + buildPlazaListHref, + buildPlazaListVisitHref, + buildPlazaListVisitLabel, + getCampaignScopeLabel, + resolvePlazaListUrl, +} from '@/utilities/plazaUi' +import { loadAdvisorSummaries } from '@/utilities/plazaViewModels' + +type PlazasPageProps = { + searchParams: Promise> +} + +export default async function PlazasPage({ searchParams }: PlazasPageProps) { + const rawSearchParams = await searchParams + const canonicalUrl = resolvePlazaListUrl(rawSearchParams) + if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) + + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) + + if (!user) return null + const isStaffView = isCampaignStaff(user) + + const [listData, overview, mapBundle] = await Promise.all([ + loadPlazaListPageData(payload, user, rawSearchParams), + loadPlazaListOverviewData(payload, user, rawSearchParams), + loadPlazaMapBundle(payload, user, canonicalUrl.state.compare), + ]) + const candidateOptions = mapBundle ? await loadFederalCandidateOptions(payload, user) : [] + const resolvedUrl = resolvePlazaListUrl(rawSearchParams, listData.totalPages) + if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) + const { state } = resolvedUrl + const listVisitLabel = buildPlazaListVisitLabel(state) + + const advisorIDs = [...new Set(listData.plazas.flatMap((plaza) => plaza.advisorIDs))] + const advisorSummaries = isStaffView ? await loadAdvisorSummaries(payload, user, advisorIDs) : [] + const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor])) + + return ( + +
+

Praças

+

+ As 436 Praças da campanha: um município cada — em Salvador e Camaçari, uma zona eleitoral + cada. +

+ + {getCampaignScopeLabel(user.role, listData.scopeTotal)} + +
+ + {mapBundle ? ( + + ) : null} + + + + {listData.plazas.length ? ( + <> + {overview ? : null} + +
+

+ {listData.totalDocs}{' '} + {listData.totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'} +

+ buildPlazaListHref(state, page)} + /> +
+ + ) : ( + + + + + Nenhuma Praça encontrada + + Ajuste a busca ou os filtros. Você só vê Praças dentro do seu escopo. + + + + + + + )} + {listVisitLabel ? ( + + ) : null} +
+ ) +} diff --git a/src/app/(campaign)/campanha/actions/actionPlan.ts b/src/app/(campaign)/campanha/actions/actionPlan.ts index 88a02089..8dfe082f 100644 --- a/src/app/(campaign)/campanha/actions/actionPlan.ts +++ b/src/app/(campaign)/campanha/actions/actionPlan.ts @@ -10,10 +10,12 @@ import { } from '@/lib/schemas/actionPlan' import type { CampaignUser } from '@/payload-types' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { isCampaignStaff } from '@/utilities/campaignAccess' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000 +const MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH = 6000 export const createActionPlanRecord = async ( payload: Payload, @@ -168,6 +170,46 @@ export const appendActionPlanUpdateRecord = async ( ) } +export const registerActionPlanResult = async ( + payload: Payload, + actor: CampaignUser, + planId: number, + resultSummary: string, + mediaIDs: number[], +) => { + const trimmedSummary = resultSummary.trim() + if (!trimmedSummary) throw new Error('Informe o resultado da ação.') + if (trimmedSummary.length > MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH) { + throw new Error('Resultado muito longo. Reduza o texto e tente novamente.') + } + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (!isCampaignStaff(currentActor)) { + throw new Error('Apenas a equipe da campanha pode registrar o resultado do plano.') + } + + return payload.update({ + collection: 'actionPlan', + id: planId, + data: { + resultSummary: trimmedSummary, + // Media upload is a follow-up; an empty list must not wipe media + // attached elsewhere (e.g. via the Payload admin). + ...(mediaIDs.length > 0 ? { resultMedia: mediaIDs } : {}), + }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível registrar o resultado do plano.' }, + ) +} + export const createActionPlan = async (input: ActionPlanCreateInput) => { const { payload, actor } = await getCampaignActionContext() return createActionPlanRecord(payload, actor, input) @@ -197,3 +239,12 @@ export const appendActionPlanUpdate = async (planId: number, body: string) => { const { payload, actor } = await getCampaignActionContext() return appendActionPlanUpdateRecord(payload, actor, planId, body) } + +export const registerActionPlanResultAction = async ( + planId: number, + resultSummary: string, + mediaIDs: number[] = [], +) => { + const { payload, actor } = await getCampaignActionContext() + return registerActionPlanResult(payload, actor, planId, resultSummary, mediaIDs) +} diff --git a/src/app/(campaign)/campanha/actions/coordinatorAssignment.ts b/src/app/(campaign)/campanha/actions/coordinatorAssignment.ts deleted file mode 100644 index e15c22ea..00000000 --- a/src/app/(campaign)/campanha/actions/coordinatorAssignment.ts +++ /dev/null @@ -1,110 +0,0 @@ -'use server' - -import type { Payload } from 'payload' -import { z } from 'zod' - -import type { CampaignUser } from '@/payload-types' -import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' -import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions' -import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks' -import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' - -const coordinatorAssignmentSchema = z.object({ - slug: z.string().trim().min(1).max(200), - coordinatorIds: z - .array(z.number().int().positive()) - .max(100) - .transform((ids) => [...new Set(ids)]), - expectedUpdatedAt: z.iso.datetime(), -}) - -export type CoordinatorAssignmentInput = z.infer - -export const assignNucleusCoordinatorsRecord = async ( - payload: Payload, - actor: CampaignUser, - input: CoordinatorAssignmentInput, -) => { - const data = coordinatorAssignmentSchema.parse(input) - return withPayloadTransaction( - payload, - async ({ req }) => { - const currentActor = await reloadCampaignActor(payload, actor, req) - if (currentActor.role !== 'geral') { - throw new Error('Somente a coordenação geral pode alterar coordenadores.') - } - - const scopedResult = await payload.find({ - collection: 'electoralNucleus', - where: { slug: { equals: data.slug } }, - depth: 0, - limit: 1, - pagination: false, - select: { updatedAt: true }, - user: currentActor, - overrideAccess: false, - req, - }) - const scopedNucleus = scopedResult.docs[0] - if (!scopedNucleus) { - throw new Error('Núcleo não encontrado ou sem acesso.') - } - - await acquireTextAdvisoryLocks(payload, req, [ - `coordinator-assignment:${scopedNucleus.id}`, - ]) - await acquireNucleusRowLocks({ payload, ...req } as never, [scopedNucleus.id]) - - // Intentional admin bypass: the actor is freshly confirmed as geral and the scoped nucleus - // is locked before revision, eligibility, and assignment checks. - const currentNucleus = await payload.findByID({ - collection: 'electoralNucleus', - id: scopedNucleus.id, - depth: 0, - select: { updatedAt: true }, - overrideAccess: true, - req, - }) - if (currentNucleus.updatedAt !== data.expectedUpdatedAt) { - throw new Error( - 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.', - ) - } - - if (data.coordinatorIds.length > 0) { - const eligible = await payload.find({ - collection: 'campaignUser', - where: { - and: [{ id: { in: data.coordinatorIds } }, eligibleNucleusCoordinatorWhere], - }, - depth: 0, - pagination: false, - select: { name: true }, - overrideAccess: true, - req, - }) - if (eligible.docs.length !== data.coordinatorIds.length) { - throw new Error( - 'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.', - ) - } - } - - return payload.update({ - collection: 'electoralNucleus', - id: currentNucleus.id, - data: { coordinators: data.coordinatorIds }, - depth: 0, - overrideAccess: true, - req, - }) - }, - { beginFailureMessage: 'Não foi possível iniciar a transação da designação.' }, - ) -} - -export const assignNucleusCoordinators = async (input: CoordinatorAssignmentInput) => { - const { payload, actor } = await getCampaignActionContext() - return assignNucleusCoordinatorsRecord(payload, actor, input) -} diff --git a/src/app/(campaign)/campanha/actions/demand.ts b/src/app/(campaign)/campanha/actions/demand.ts new file mode 100644 index 00000000..8497af9b --- /dev/null +++ b/src/app/(campaign)/campanha/actions/demand.ts @@ -0,0 +1,223 @@ +'use server' + +import type { Payload } from 'payload' + +import { + campaignDemandCostSchema, + campaignDemandCreateSchema, + campaignDemandDetailsUpdateSchema, + campaignDemandTransitionSchema, + type CampaignDemandCostInput, + type CampaignDemandCreateInput, + type CampaignDemandDetailsUpdateInput, + type CampaignDemandTransitionInput, +} from '@/lib/schemas/campaignDemandInput' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' + +export const createCampaignDemandRecord = async ( + payload: Payload, + actor: CampaignUser, + input: CampaignDemandCreateInput, +) => { + const data = campaignDemandCreateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + + // Access create rule validates plaza scope per role; the collection hook + // links a leader's own leadership and enforces the initial status. + return payload.create({ + collection: 'campaignDemand', + data: data as never, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar o registro da demanda.' }, + ) +} + +export const updateCampaignDemandDetailsRecord = async ( + payload: Payload, + actor: CampaignUser, + input: CampaignDemandDetailsUpdateInput, +) => { + const { id, ...data } = campaignDemandDetailsUpdateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`]) + + return payload.update({ + collection: 'campaignDemand', + id, + data, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a atualização da demanda.' }, + ) +} + +export const transitionCampaignDemandRecord = async ( + payload: Payload, + actor: CampaignUser, + input: CampaignDemandTransitionInput, +) => { + const { id, status, decisionNote } = campaignDemandTransitionSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria movem demandas.') + } + await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`]) + + // Transition validity + coordinator-only escalated decisions are + // enforced by the collection hook. + return payload.update({ + collection: 'campaignDemand', + id, + data: { + status, + ...(decisionNote === undefined ? {} : { decisionNote }), + }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a transição da demanda.' }, + ) +} + +export const setCampaignDemandCostRecord = async ( + payload: Payload, + actor: CampaignUser, + input: CampaignDemandCostInput, +) => { + const { id, cost } = campaignDemandCostSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria registram custos.') + } + await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`]) + + return payload.update({ + collection: 'campaignDemand', + id, + data: { cost }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar o registro do custo.' }, + ) +} + +const RECEIPT_MAX_BYTES = 10 * 1024 * 1024 +const RECEIPT_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']) + +export const attachCampaignDemandReceiptRecord = async ( + payload: Payload, + actor: CampaignUser, + demandID: number, + file: File, +) => { + if (!RECEIPT_MIME_TYPES.has(file.type)) { + throw new Error('Envie uma imagem (JPEG, PNG, WebP) ou PDF.') + } + 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.') + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria anexam comprovantes.') + } + await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${demandID}`]) + + // Row access verifies the demand is in the actor's scope before writing. + const demand = await payload.findByID({ + collection: 'campaignDemand', + id: demandID, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + // Intentional admin bypass: media creation for a receipt the actor may attach. + const media = await payload.create({ + collection: 'media', + data: { alt: `Comprovante — ${demand.title}` }, + file: { + data: Buffer.from(await file.arrayBuffer()), + mimetype: file.type, + name: file.name, + size: file.size, + }, + overrideAccess: true, + req, + }) + + const currentReceipts = (demand.receipts ?? []).map((receipt) => + typeof receipt === 'object' && receipt !== null ? receipt.id : receipt, + ) + + return payload.update({ + collection: 'campaignDemand', + id: demandID, + data: { receipts: [...currentReceipts, media.id] }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar o anexo do comprovante.' }, + ) +} + +export const createCampaignDemand = async (input: CampaignDemandCreateInput) => { + const { payload, actor } = await getCampaignActionContext() + return createCampaignDemandRecord(payload, actor, input) +} + +export const updateCampaignDemandDetails = async (input: CampaignDemandDetailsUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + return updateCampaignDemandDetailsRecord(payload, actor, input) +} + +export const transitionCampaignDemand = async (input: CampaignDemandTransitionInput) => { + const { payload, actor } = await getCampaignActionContext() + return transitionCampaignDemandRecord(payload, actor, input) +} + +export const setCampaignDemandCost = async (input: CampaignDemandCostInput) => { + const { payload, actor } = await getCampaignActionContext() + return setCampaignDemandCostRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 78647e74..9764e91c 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -8,11 +8,11 @@ import { type LeadershipInternalUpdateInput, } from '@/lib/schemas/leadership' import type { CampaignUser, Contact } from '@/payload-types' +import { getAdvisorPlazaIds } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { requireRelationshipId } from '@/utilities/relationship' const getFreshStaffActor = async ( payload: Payload, @@ -21,38 +21,38 @@ const getFreshStaffActor = async ( ): Promise => { const currentActor = await reloadCampaignActor(payload, actor, req) - if (currentActor.role !== 'geral' && currentActor.role !== 'coordenador') { - throw new Error('Somente a coordenação pode gerenciar lideranças.') + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria podem gerenciar lideranças.') } return currentActor } -const assertNucleusManagement = async ( +/** Advisors may only link leaderships to plazas they administer. */ +const assertPlazasWithinScope = async ( payload: Payload, actor: CampaignUser, - nucleusID: number, + plazaIDs: number[], req?: PayloadTransactionRequest, -) => - payload.findByID({ - collection: 'electoralNucleus', - id: nucleusID, - depth: 0, - user: actor, - overrideAccess: false, - req, - }) +) => { + if (actor.role !== 'advisor') return + + const administered = new Set(await getAdvisorPlazaIds(payload, actor.id, req)) + const outside = plazaIDs.filter((id) => !administered.has(id)) + if (outside.length > 0) { + throw new Error('Você só pode vincular lideranças às Praças que assessora.') + } +} const isUniqueLeadershipConflict = (error: unknown): boolean => { const message = error instanceof Error ? error.message : String(error) - if (/leadership.*contact.*nucleus|leadership_contact_nucleus|duplicate key/i.test(message)) { + if (/leadership_contact|duplicate key/i.test(message)) { return true } if (!(error instanceof Error) || error.name !== 'ValidationError') return false - const details = JSON.stringify(error) - return /contact(?:_id)?/i.test(details) && /nucleus(?:_id)?/i.test(details) + return /contact(?:_id)?/i.test(JSON.stringify(error)) } type LeadershipCreateData = ReturnType @@ -67,7 +67,7 @@ const createValidatedLeadershipRecord = async ( payload, async ({ req }) => { const currentActor = await getFreshStaffActor(payload, actor, req) - const nucleus = await assertNucleusManagement(payload, currentActor, data.nucleus, req) + await assertPlazasWithinScope(payload, currentActor, data.plazas, req) if (payload.db.name !== 'postgres') { throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.') } @@ -94,8 +94,18 @@ const createValidatedLeadershipRecord = async ( const contactReused = Boolean(contactID) if (!contactID) { - const cities = Array.isArray(nucleus.cities) ? nucleus.cities : [] - const city = cities.length === 1 ? cities[0] : (nucleus.locality ?? null) + let city: string | null = null + if (data.plazas.length === 1) { + const plaza = await payload.findByID({ + collection: 'plaza', + id: data.plazas[0]!, + depth: 0, + select: { city: true }, + overrideAccess: true, + req, + }) + city = plaza.city + } const contact = await payload.create({ collection: 'contact', data: { @@ -117,7 +127,8 @@ const createValidatedLeadershipRecord = async ( collection: 'leadership', data: { contact: contactID, - nucleus: data.nucleus, + plazas: data.plazas, + organizations: data.organizations ?? [], sector: data.sector, sectorNotes: data.sectorNotes, supportStatus: data.supportStatus, @@ -135,7 +146,9 @@ const createValidatedLeadershipRecord = async ( ) } catch (error) { if (isUniqueLeadershipConflict(error)) { - throw new Error('Esta pessoa já está cadastrada como liderança neste núcleo.') + throw new Error( + 'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novas Praças.', + ) } throw error @@ -153,37 +166,46 @@ export const updateLeadershipInternalRecord = async ( actor: CampaignUser, input: LeadershipInternalUpdateInput, ) => { - const { id, ...data } = leadershipInternalUpdateSchema.parse(input) + const { id, plazas, organizations, ...data } = leadershipInternalUpdateSchema.parse(input) const currentActor = await getFreshStaffActor(payload, actor) + + // Row access verifies the current record is in the actor's scope. const current = await payload.findByID({ collection: 'leadership', id, depth: 0, - overrideAccess: true, + user: currentActor, + overrideAccess: false, }) - await assertNucleusManagement(payload, currentActor, requireRelationshipId(current.nucleus)) + + if (plazas !== undefined) { + await assertPlazasWithinScope(payload, currentActor, plazas) + } return payload.update({ collection: 'leadership', - id, - data, + id: current.id, + data: { + ...data, + ...(plazas === undefined ? {} : { plazas }), + ...(organizations === undefined ? {} : { organizations: organizations ?? [] }), + }, depth: 0, user: currentActor, overrideAccess: false, }) } -export const listNucleusLeaderships = async ( +export const listPlazaLeaderships = async ( payload: Payload, actor: CampaignUser, - nucleusID: number, + plazaID: number, ) => { const currentActor = await getFreshStaffActor(payload, actor) - await assertNucleusManagement(payload, currentActor, nucleusID) return payload.find({ collection: 'leadership', - where: { nucleus: { equals: nucleusID } }, + where: { plazas: { in: [plazaID] } }, depth: 1, sort: 'createdAt', user: currentActor, diff --git a/src/app/(campaign)/campanha/actions/nucleus.ts b/src/app/(campaign)/campanha/actions/nucleus.ts deleted file mode 100644 index bf81322b..00000000 --- a/src/app/(campaign)/campanha/actions/nucleus.ts +++ /dev/null @@ -1,94 +0,0 @@ -'use server' - -import type { Payload } from 'payload' - -import { - nucleusCreateSchema, - nucleusUpdateSchema, - type NucleusCreateInput, - type NucleusUpdateInput, -} from '@/lib/schemas/nucleus' -import type { CampaignUser } from '@/payload-types' -import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' -import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks' -import { withPayloadTransaction } from '@/utilities/payloadTransaction' - -export const createElectoralNucleus = async ( - payload: Payload, - actor: CampaignUser, - input: NucleusCreateInput, -) => { - const data = nucleusCreateSchema.parse(input) - - return payload.create({ - collection: 'electoralNucleus', - data: data as never, - depth: 0, - user: actor, - overrideAccess: false, - }) -} - -export const updateElectoralNucleus = async ( - payload: Payload, - actor: CampaignUser, - input: NucleusUpdateInput, -) => { - const { id, ...data } = nucleusUpdateSchema.parse(input) - return withPayloadTransaction( - payload, - async ({ req }) => { - const currentActor = await reloadCampaignActor(payload, actor, req) - await acquireNucleusRowLocks({ payload, ...req } as never, [id]) - - return payload.update({ - collection: 'electoralNucleus', - id, - data: data as never, - depth: 0, - user: currentActor, - overrideAccess: false, - req, - }) - }, - { beginFailureMessage: 'Não foi possível iniciar a atualização do núcleo.' }, - ) -} - -export const archiveElectoralNucleus = async ( - payload: Payload, - actor: CampaignUser, - id: number, -) => { - const currentActor = await reloadCampaignActor(payload, actor) - - if (currentActor.role !== 'geral') { - throw new Error('Somente a coordenação geral pode arquivar núcleos.') - } - - return payload.update({ - collection: 'electoralNucleus', - id, - data: { - status: 'arquivado', - }, - depth: 0, - user: currentActor, - overrideAccess: false, - }) -} - -export const createNucleus = async (input: NucleusCreateInput) => { - const { payload, actor } = await getCampaignActionContext() - return createElectoralNucleus(payload, actor, input) -} - -export const updateNucleus = async (input: NucleusUpdateInput) => { - const { payload, actor } = await getCampaignActionContext() - return updateElectoralNucleus(payload, actor, input) -} - -export const archiveNucleus = async (id: number) => { - const { payload, actor } = await getCampaignActionContext() - return archiveElectoralNucleus(payload, actor, id) -} diff --git a/src/app/(campaign)/campanha/actions/nucleusUpdate.ts b/src/app/(campaign)/campanha/actions/nucleusUpdate.ts deleted file mode 100644 index 73a027c8..00000000 --- a/src/app/(campaign)/campanha/actions/nucleusUpdate.ts +++ /dev/null @@ -1,63 +0,0 @@ -'use server' - -import type { Payload } from 'payload' - -import { - nucleusUpdateCreateSchema, - type NucleusUpdateCreateInput, -} from '@/lib/schemas/nucleusUpdate' -import type { CampaignUser } from '@/payload-types' -import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' -import { withPayloadTransaction } from '@/utilities/payloadTransaction' - -export const createNucleusUpdateRecord = async ( - payload: Payload, - actor: CampaignUser, - input: NucleusUpdateCreateInput, -) => { - const data = nucleusUpdateCreateSchema.parse(input) - return withPayloadTransaction( - payload, - async ({ req }) => { - const currentActor = await reloadCampaignActor(payload, actor, req) - return payload.create({ - collection: 'nucleusUpdate', - data: data as never, - depth: 0, - user: currentActor, - overrideAccess: false, - req, - }) - }, - { beginFailureMessage: 'Não foi possível iniciar a transação da atualização.' }, - ) -} - -export const listNucleusUpdates = async ( - payload: Payload, - actor: CampaignUser, - nucleus: number, -) => { - const currentActor = await reloadCampaignActor(payload, actor) - await payload.findByID({ - collection: 'electoralNucleus', - id: nucleus, - depth: 0, - user: currentActor, - overrideAccess: false, - }) - - return payload.find({ - collection: 'nucleusUpdate', - where: { nucleus: { equals: nucleus } }, - depth: 1, - sort: '-createdAt', - user: currentActor, - overrideAccess: false, - }) -} - -export const createNucleusUpdate = async (input: NucleusUpdateCreateInput) => { - const { payload, actor } = await getCampaignActionContext() - return createNucleusUpdateRecord(payload, actor, input) -} diff --git a/src/app/(campaign)/campanha/actions/organization.ts b/src/app/(campaign)/campanha/actions/organization.ts new file mode 100644 index 00000000..8c81e8d4 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/organization.ts @@ -0,0 +1,77 @@ +'use server' + +import type { Payload } from 'payload' + +import { + organizationCreateSchema, + organizationUpdateSchema, + type OrganizationCreateInput, + type OrganizationUpdateInput, +} from '@/lib/schemas/organization' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' + +const assertStaffActor = async (payload: Payload, actor: CampaignUser): Promise => { + const currentActor = await reloadCampaignActor(payload, actor) + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria gerenciam organizações.') + } + return currentActor +} + +const isUniqueOrganizationConflict = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error) + return /organization_(name|slug)|duplicate key/i.test(message) +} + +export const createOrganizationRecord = async ( + payload: Payload, + actor: CampaignUser, + input: OrganizationCreateInput, +) => { + const data = organizationCreateSchema.parse(input) + const currentActor = await assertStaffActor(payload, actor) + + try { + return await payload.create({ + collection: 'organization', + data: data as never, + depth: 0, + user: currentActor, + overrideAccess: false, + }) + } catch (error) { + if (isUniqueOrganizationConflict(error)) { + throw new Error('Já existe uma organização com este nome.') + } + throw error + } +} + +export const updateOrganizationRecord = async ( + payload: Payload, + actor: CampaignUser, + input: OrganizationUpdateInput, +) => { + const { id, ...data } = organizationUpdateSchema.parse(input) + const currentActor = await assertStaffActor(payload, actor) + + return payload.update({ + collection: 'organization', + id, + data, + depth: 0, + user: currentActor, + overrideAccess: false, + }) +} + +export const createOrganization = async (input: OrganizationCreateInput) => { + const { payload, actor } = await getCampaignActionContext() + return createOrganizationRecord(payload, actor, input) +} + +export const updateOrganization = async (input: OrganizationUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + return updateOrganizationRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/plaza.ts b/src/app/(campaign)/campanha/actions/plaza.ts new file mode 100644 index 00000000..76d285e7 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/plaza.ts @@ -0,0 +1,123 @@ +'use server' + +import type { Payload } from 'payload' + +import { + plazaAdvisorsAssignmentSchema, + plazaPoliticalTrendSchema, + plazaStrategyUpdateSchema, + type PlazaAdvisorsAssignmentInput, + type PlazaPoliticalTrendInput, + type PlazaStrategyUpdateInput, +} from '@/lib/schemas/plaza' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' + +const getFreshStaffActor = async ( + payload: Payload, + actor: CampaignUser, + req?: PayloadTransactionRequest, +): Promise => { + const currentActor = await reloadCampaignActor(payload, actor, req) + + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria podem editar a Praça.') + } + + return currentActor +} + +/** Staff strategy fields: goals, priority, intel notes. Access enforced by row scope. */ +export const updatePlazaStrategyRecord = async ( + payload: Payload, + actor: CampaignUser, + input: PlazaStrategyUpdateInput, +) => { + const { plaza, strengths, risks, ...fields } = plazaStrategyUpdateSchema.parse(input) + const currentActor = await getFreshStaffActor(payload, actor) + + return payload.update({ + collection: 'plaza', + id: plaza, + data: { + ...fields, + ...(strengths === undefined ? {} : { strengths: strengths.map((text) => ({ text })) }), + ...(risks === undefined ? {} : { risks: risks.map((text) => ({ text })) }), + }, + depth: 0, + user: currentActor, + overrideAccess: false, + }) +} + +export const updatePlazaStrategy = async (input: PlazaStrategyUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + return updatePlazaStrategyRecord(payload, actor, input) +} + +/** Political trend: manual conjuncture reading by staff (author/date derived by hook). */ +export const setPlazaPoliticalTrendRecord = async ( + payload: Payload, + actor: CampaignUser, + input: PlazaPoliticalTrendInput, +) => { + const { plaza, status, note } = plazaPoliticalTrendSchema.parse(input) + const currentActor = await getFreshStaffActor(payload, actor) + + return payload.update({ + collection: 'plaza', + id: plaza, + data: { + politicalTrend: { status, note }, + }, + depth: 0, + user: currentActor, + overrideAccess: false, + }) +} + +export const setPlazaPoliticalTrend = async (input: PlazaPoliticalTrendInput) => { + const { payload, actor } = await getCampaignActionContext() + return setPlazaPoliticalTrendRecord(payload, actor, input) +} + +/** Advisor assignment is coordinator-only; the collection hook validates eligibility. */ +export const assignPlazaAdvisorsRecord = async ( + payload: Payload, + actor: CampaignUser, + input: PlazaAdvisorsAssignmentInput, +) => { + const { plaza, advisors } = plazaAdvisorsAssignmentSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (currentActor.role !== 'coordinator') { + throw new Error('Somente o Coordenador Geral designa assessores.') + } + + await acquireTextAdvisoryLocks(payload, req, [`plaza-advisors:${plaza}`]) + + // Intentional admin bypass: coordinator role was freshly verified above; + // the advisors field is admin-only by field access. + return payload.update({ + collection: 'plaza', + id: plaza, + data: { advisors }, + depth: 0, + overrideAccess: true, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a designação de assessores.' }, + ) +} + +export const assignPlazaAdvisors = async (input: PlazaAdvisorsAssignmentInput) => { + const { payload, actor } = await getCampaignActionContext() + return assignPlazaAdvisorsRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/plazaUpdate.ts b/src/app/(campaign)/campanha/actions/plazaUpdate.ts new file mode 100644 index 00000000..c7d85424 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/plazaUpdate.ts @@ -0,0 +1,38 @@ +'use server' + +import type { Payload } from 'payload' + +import { plazaUpdateCreateSchema, type PlazaUpdateCreateInput } from '@/lib/schemas/plazaUpdate' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' + +export const createPlazaUpdateRecord = async ( + payload: Payload, + actor: CampaignUser, + input: PlazaUpdateCreateInput, +) => { + const data = plazaUpdateCreateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + + return payload.create({ + collection: 'plazaUpdate', + data: data as never, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar o registro da atualização.' }, + ) +} + +export const createPlazaUpdate = async (input: PlazaUpdateCreateInput) => { + const { payload, actor } = await getCampaignActionContext() + return createPlazaUpdateRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 93f2fded..1b162680 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -72,34 +72,34 @@ const getFreshStaffActor = async ( ): Promise => { const currentActor = await reloadCampaignActor(payload, actor, req) - if (currentActor.role !== 'geral' && currentActor.role !== 'coordenador') { - throw new Error('Somente a coordenação pode gerenciar apoiadores.') + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria podem gerenciar apoiadores.') } return currentActor } -const getFreshGeneralActor = async ( +const getFreshCoordinatorActor = async ( payload: Payload, actor: CampaignUser, req?: PayloadTransactionRequest, ): Promise => { const currentActor = await reloadCampaignActor(payload, actor, req) - if (currentActor.role !== 'geral') { - throw new Error('Somente a coordenação geral pode importar apoiadores.') + if (currentActor.role !== 'coordinator') { + throw new Error('Somente o Coordenador Geral pode importar apoiadores.') } return currentActor } -const assertNucleusManagement = async ( +const assertPlazaManagement = async ( payload: Payload, actor: CampaignUser, - nucleusID: number, + plazaID: number, req?: PayloadTransactionRequest, ) => payload.findByID({ - collection: 'electoralNucleus', - id: nucleusID, + collection: 'plaza', + id: plazaID, depth: 0, user: actor, overrideAccess: false, @@ -123,7 +123,7 @@ const assertCanManageSupporter = async ( const isUniqueSupporterConflict = (error: unknown): boolean => { const message = error instanceof Error ? error.message : String(error) - return /supporter.*contact.*nucleus|supporter_contact_nucleus|nulls_not_distinct|duplicate key/i.test( + return /supporter.*contact.*plaza|supporter_contact_plaza|nulls_not_distinct|duplicate key/i.test( message, ) } @@ -194,10 +194,10 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i async ({ req }) => { const currentActor = await getFreshStaffActor(payload, actor, req) - if (data.nucleus) { - await assertNucleusManagement(payload, currentActor, data.nucleus, req) - } else if (currentActor.role !== 'geral') { - throw new Error('Somente a coordenação geral pode cadastrar apoiadores sem núcleo.') + if (data.plaza) { + await assertPlazaManagement(payload, currentActor, data.plaza, req) + } else if (currentActor.role !== 'coordinator') { + throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.') } const registrationConsent = await requireSupporterRegistrationConsent( @@ -233,7 +233,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i collection: 'supporter', data: { contact: contactID, - nucleus: data.nucleus, + plaza: data.plaza, voteIntention: data.voteIntention, source: 'manual', consent: registrationConsent.id, @@ -259,7 +259,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 núcleo.') + throw new Error('Esta pessoa já está cadastrada como apoiador nesta Praça.') } throw error } @@ -363,7 +363,7 @@ export const previewSupporterImportText = async ( actor: CampaignUser, csvText: string, ): Promise => { - const currentActor = await getFreshGeneralActor(payload, actor) + const currentActor = await getFreshCoordinatorActor(payload, actor) await requireSupporterRegistrationConsent(payload) await requireSupporterVoteIntentionConsent(payload) @@ -466,7 +466,7 @@ export const previewSupporterImportText = async ( }) } - // Duplicate against an existing supporter without nucleus (Contact reuse alone is OK). + // Duplicate against an existing supporter without plaza (Contact reuse alone is OK). const candidatePhones = [ ...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)), ] @@ -488,7 +488,7 @@ export const previewSupporterImportText = async ( const existingSupporters = await payload.find({ collection: 'supporter', where: { - and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }], + and: [{ contact: { in: contactIDs } }, { plaza: { exists: false } }], }, depth: 0, limit: contactIDs.length, @@ -570,7 +570,7 @@ export const confirmSupporterImportRecord = async ( return withPayloadTransaction( payload, async ({ req }) => { - const currentActor = await getFreshGeneralActor(payload, actor, req) + const currentActor = await getFreshCoordinatorActor(payload, actor, req) const registrationConsent = await requireSupporterRegistrationConsent(payload, req) const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req) diff --git a/src/app/(campaign)/campanha/actions/voteEstimate.ts b/src/app/(campaign)/campanha/actions/voteEstimate.ts deleted file mode 100644 index 92476d4e..00000000 --- a/src/app/(campaign)/campanha/actions/voteEstimate.ts +++ /dev/null @@ -1,179 +0,0 @@ -'use server' - -import { randomUUID } from 'node:crypto' -import type { Payload } from 'payload' - -import { - confirmVoteEstimateSchema, - suggestVoteEstimateSchema, - type ConfirmVoteEstimateInput, - type SuggestVoteEstimateInput, -} from '@/lib/schemas/voteEstimate' -import type { CampaignUser, ElectoralNucleus } from '@/payload-types' -import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' -import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' -import { withPayloadTransaction } from '@/utilities/payloadTransaction' -import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' - -const assertSuggestionScope = async ( - payload: Payload, - actor: CampaignUser, - nucleus: number, - req: PayloadTransactionRequest, -): Promise => { - if (actor.role === 'geral') return - - if (actor.role === 'coordenador') { - await payload.findByID({ - collection: 'electoralNucleus', - id: nucleus, - depth: 0, - user: actor, - overrideAccess: false, - req, - }) - return - } - - const links = await payload.find({ - collection: 'leadership', - where: { - and: [ - { nucleus: { equals: nucleus } }, - { user: { equals: actor.id } }, - { supportStatus: { equals: 'engajado' } }, - ], - }, - depth: 0, - limit: 1, - pagination: false, - overrideAccess: true, - req, - }) - - if (links.totalDocs === 0) { - throw new Error('A liderança precisa de vínculo engajado com este núcleo.') - } -} - -const getConfirmableNucleus = async ( - payload: Payload, - actor: CampaignUser, - nucleus: number, - req: PayloadTransactionRequest, -): Promise => { - if (actor.role !== 'geral' && actor.role !== 'coordenador') { - throw new Error('Somente a coordenação pode confirmar estimativas.') - } - - return payload.findByID({ - collection: 'electoralNucleus', - id: nucleus, - depth: 0, - user: actor, - overrideAccess: false, - req, - }) -} - -export const suggestVoteEstimateRecord = async ( - payload: Payload, - actor: CampaignUser, - input: SuggestVoteEstimateInput, -) => { - const data = suggestVoteEstimateSchema.parse(input) - return withPayloadTransaction( - payload, - async ({ req }) => { - if (payload.db.name !== 'postgres') { - throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.') - } - await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`]) - const currentActor = await reloadCampaignActor(payload, actor, req) - await assertSuggestionScope(payload, currentActor, data.nucleus, req) - - // Intentional admin bypass: scope was checked under the nucleus transaction lock; audit - // fields and proposal version are derived exclusively on the server. - return payload.update({ - collection: 'electoralNucleus', - id: data.nucleus, - data: { - proposedVoteEstimate: data.estimate, - proposedVoteEstimateAt: new Date().toISOString(), - proposedVoteEstimateBy: currentActor.id, - proposedVoteEstimateVersion: randomUUID(), - }, - depth: 0, - overrideAccess: true, - req, - }) - }, - { beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' }, - ) -} - -export const confirmVoteEstimateRecord = async ( - payload: Payload, - actor: CampaignUser, - input: ConfirmVoteEstimateInput, -) => { - const data = confirmVoteEstimateSchema.parse(input) - return withPayloadTransaction( - payload, - async ({ req }) => { - if (payload.db.name !== 'postgres') { - throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.') - } - await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`]) - const currentActor = await reloadCampaignActor(payload, actor, req) - const nucleus = await getConfirmableNucleus(payload, currentActor, data.nucleus, req) - const proposal = nucleus.proposedVoteEstimate - const proposalVersion = nucleus.proposedVoteEstimateVersion ?? null - - if (proposal == null) { - if (proposalVersion !== null || data.expectedProposedVoteEstimateVersion !== null) { - throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.') - } - } else if ( - proposalVersion === null || - proposalVersion !== data.expectedProposedVoteEstimateVersion - ) { - throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.') - } - if ((proposal == null || data.estimate !== proposal) && !data.confirmationNote) { - throw new Error('Informe uma justificativa para ajustar a estimativa.') - } - - // Intentional admin bypass: confirmation role/scope and proposal revision were checked - // under the same transaction lock; all audit fields are server-derived. - return payload.update({ - collection: 'electoralNucleus', - id: data.nucleus, - data: { - confirmedVoteEstimate: data.estimate, - confirmedVoteEstimateAt: new Date().toISOString(), - confirmedVoteEstimateBy: currentActor.id, - confirmationNote: data.confirmationNote ?? null, - proposedVoteEstimate: null, - proposedVoteEstimateAt: null, - proposedVoteEstimateBy: null, - proposedVoteEstimateVersion: null, - }, - depth: 0, - overrideAccess: true, - req, - }) - }, - { beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' }, - ) -} - -export const suggestVoteEstimate = async (input: SuggestVoteEstimateInput) => { - const { payload, actor } = await getCampaignActionContext() - return suggestVoteEstimateRecord(payload, actor, input) -} - -export const confirmVoteEstimate = async (input: ConfirmVoteEstimateInput) => { - const { payload, actor } = await getCampaignActionContext() - return confirmVoteEstimateRecord(payload, actor, input) -} diff --git a/src/app/(campaign)/campanha/actions/votePledge.ts b/src/app/(campaign)/campanha/actions/votePledge.ts new file mode 100644 index 00000000..9e2fca06 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/votePledge.ts @@ -0,0 +1,172 @@ +'use server' + +import type { Payload } from 'payload' + +import { + declareVotesSchema, + estimateVotesSchema, + type DeclareVotesInput, + type EstimateVotesInput, +} from '@/lib/schemas/votePledge' +import type { CampaignUser } from '@/payload-types' +import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' +import { relationshipId, requireRelationshipId } from '@/utilities/relationship' + +const OWN_ENGAGED_LEADERSHIP_REQUIRED = 'Somente lideranças engajadas podem declarar votos.' +const PLAZA_NOT_LINKED = 'A liderança precisa estar vinculada à Praça para declarar votos nela.' + +/** + * Declare (or update) the votes a leadership is bringing in one plaza. + * A leader always declares for their own leadership; staff may declare on + * behalf of any leadership in their scope. + */ +export const declareVotesRecord = async ( + payload: Payload, + actor: CampaignUser, + input: DeclareVotesInput, +) => { + const data = declareVotesSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + + let leadershipID: number + if (currentActor.role === 'leader') { + const ownLeadership = await payload.find({ + collection: 'leadership', + where: { + and: [{ user: { equals: currentActor.id } }, { supportStatus: { equals: 'engajado' } }], + }, + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + req, + }) + const leadership = ownLeadership.docs[0] + if (!leadership) throw new Error(OWN_ENGAGED_LEADERSHIP_REQUIRED) + leadershipID = leadership.id + + const linkedPlazaIDs = (leadership.plazas ?? []) + .map(relationshipId) + .filter((id): id is number => id !== null) + if (!linkedPlazaIDs.includes(data.plaza)) throw new Error(PLAZA_NOT_LINKED) + } else { + if (!data.leadership) throw new Error('Informe a liderança da declaração.') + leadershipID = data.leadership + // Row access on the plaza read verifies the staff scope (advisor only + // reaches administered plazas). + await payload.findByID({ + collection: 'plaza', + id: data.plaza, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + } + + await acquireTextAdvisoryLocks(payload, req, [`vote-pledge:${leadershipID}:${data.plaza}`]) + + const existing = await payload.find({ + collection: 'votePledge', + where: { + and: [{ leadership: { equals: leadershipID } }, { plaza: { equals: data.plaza } }], + }, + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + req, + }) + const current = existing.docs[0] + + if (current) { + return payload.update({ + collection: 'votePledge', + id: current.id, + data: { declaredVotes: data.declaredVotes }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + } + + return payload.create({ + collection: 'votePledge', + data: { + leadership: leadershipID, + plaza: data.plaza, + declaredVotes: data.declaredVotes, + }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a declaração de votos.' }, + ) +} + +export const declareVotes = async (input: DeclareVotesInput) => { + const { payload, actor } = await getCampaignActionContext() + return declareVotesRecord(payload, actor, input) +} + +/** Staff estimate of the real value — never visible to the leadership. */ +export const estimateVotesRecord = async ( + payload: Payload, + actor: CampaignUser, + input: EstimateVotesInput, +) => { + const data = estimateVotesSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') { + throw new Error('Somente a coordenação e a assessoria registram estimativas.') + } + + // Row access scopes the pledge to the actor (advisor: administered plazas). + const pledge = await payload.findByID({ + collection: 'votePledge', + id: data.pledge, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + await acquireTextAdvisoryLocks(payload, req, [ + `vote-pledge:${requireRelationshipId(pledge.leadership)}:${requireRelationshipId(pledge.plaza)}`, + ]) + + return payload.update({ + collection: 'votePledge', + id: pledge.id, + data: { + estimatedVotes: data.estimatedVotes, + estimateNote: data.estimateNote, + }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar o registro da estimativa.' }, + ) +} + +export const estimateVotes = async (input: EstimateVotesInput) => { + const { payload, actor } = await getCampaignActionContext() + return estimateVotesRecord(payload, actor, input) +} diff --git a/src/app/(campaign)/layout.tsx b/src/app/(campaign)/layout.tsx index 01962fe3..d0597864 100644 --- a/src/app/(campaign)/layout.tsx +++ b/src/app/(campaign)/layout.tsx @@ -3,7 +3,6 @@ import { Inter } from 'next/font/google' import React from 'react' import { RegisterServiceWorker } from '@/components/campaign/RegisterServiceWorker' -import { VoteEstimateFocusProvider } from '@/components/campaign/VoteEstimateFocusProvider' import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa' import '../(frontend)/styles.css' @@ -40,7 +39,7 @@ export default function CampanhaRootLayout({ children }: { children: React.React style={{ colorScheme: 'light' }} > - {children} + {children} diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts index 93f13f07..9df34218 100644 --- a/src/collections/ActionPlan.ts +++ b/src/collections/ActionPlan.ts @@ -5,30 +5,23 @@ import type { } from 'payload' import { APIError } from 'payload' -import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories' import { actionPlanKindLabels, actionPlanKinds, actionPlanStatusLabels, actionPlanStatuses, } from '@/lib/schemas/actionPlan' -import { - MAX_NUCLEUS_CITIES, - MAX_NUCLEUS_NEIGHBORHOODS, - MAX_NUCLEUS_REGIONS, -} from '@/lib/schemas/nucleus' import { canCreateActionPlan, - canCreateActionPlanCoordinators, + canCreateActionPlanAdvisors, canDeleteActionPlan, - canManageActionPlanCoordinators, + canManageActionPlanAdvisors, canReadActionPlan, canSetActionPlanStatus, canSetActionPlanSystemField, canUpdateActionPlan, + eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' -import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation' -import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions' import { relationshipId } from '@/utilities/relationship' import { slugify } from '@/utilities/slug' @@ -47,6 +40,9 @@ const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({ label: actionPlanStatusLabels[value], })) +const relationshipIds = (value: unknown): number[] => + (Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null) + const actionPlanStaffFieldSnapshot = (doc: Record) => ({ title: trimmedText(doc.title), slug: doc.slug ?? null, @@ -56,27 +52,17 @@ const actionPlanStaffFieldSnapshot = (doc: Record) => ({ startAt: doc.startAt ?? null, endAt: doc.endAt ?? null, deadline: doc.deadline ?? null, - regions: Array.isArray(doc.regions) ? doc.regions.map(String) : [], - cities: Array.isArray(doc.cities) ? doc.cities.map(String) : [], - neighborhoods: Array.isArray(doc.neighborhoods) ? doc.neighborhoods.map(String) : [], + plaza: relationshipId(doc.plaza), locality: trimmedText(doc.locality), - territoryNotes: trimmedText(doc.territoryNotes), - coordinators: (Array.isArray(doc.coordinators) ? doc.coordinators : []) - .map(relationshipId) - .filter((id): id is number => id !== null), + deputyPresent: Boolean(doc.deputyPresent), + organizations: relationshipIds(doc.organizations), + advisors: relationshipIds(doc.advisors), responsible: relationshipId(doc.responsible), leadership: relationshipId(doc.leadership), + resultSummary: trimmedText(doc.resultSummary), + resultMedia: relationshipIds(doc.resultMedia), }) -const validateActionPlanTerritoryCore = createCampaignTerritoryValidationHook({ - entityLabel: 'plano', -}) - -const validateActionPlanTerritory: CollectionBeforeValidateHook = (args) => { - if (isActionPlanMutationShortcut(args.context)) return args.data - return validateActionPlanTerritoryCore(args) -} - const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({ data, operation, @@ -113,10 +99,7 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({ const endAt = nextData.endAt ?? null if (status !== 'rascunho' && !startAt) { - throw new APIError( - 'Informe a data e horário de início ao planejar ou confirmar o plano.', - 400, - ) + throw new APIError('Informe a data e horário de início ao planejar ou confirmar o plano.', 400) } if (startAt && endAt) { @@ -130,48 +113,48 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({ return data } -const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req, context }) => { +const validateActionPlanAdvisors: CollectionBeforeValidateHook = async ({ data, req, context }) => { if (isActionPlanMutationShortcut(context)) return data if (!data) return data - if (data.coordinators === undefined) return data + if (data.advisors === undefined) return data - const coordinatorValues = data.coordinators - const coordinatorIDs = Array.isArray(coordinatorValues) - ? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))] - : [] + const advisorIDs = [...new Set(relationshipIds(data.advisors))] + if (advisorIDs.length === 0) return data - if (coordinatorIDs.length === 0) return data - - const eligibleCoordinators = await req.payload.find({ + const eligibleAdvisors = await req.payload.find({ collection: 'campaignUser', depth: 0, pagination: false, where: { - and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere], + and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere], }, select: { name: true }, overrideAccess: true, req, }) - if (eligibleCoordinators.docs.length !== coordinatorIDs.length) { - throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400) + if (eligibleAdvisors.docs.length !== advisorIDs.length) { + throw new APIError('Cada responsável deve ter papel de Coordenador Geral ou Assessor.', 400) } return data } -const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, originalDoc, req }) => { +const deriveActionPlanFields: CollectionBeforeChangeHook = ({ + data, + operation, + originalDoc, + req, +}) => { if (!data) return data if (operation === 'create' && req.user?.collection === 'campaignUser') { data.createdBy = req.user.id if ( - req.user.role === 'coordenador' && - (data.coordinators === undefined || - (Array.isArray(data.coordinators) && data.coordinators.length === 0)) + req.user.role === 'advisor' && + (data.advisors === undefined || (Array.isArray(data.advisors) && data.advisors.length === 0)) ) { - data.coordinators = [req.user.id] + data.advisors = [req.user.id] } } @@ -204,16 +187,29 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o } return { body: trimmedText(update.body), - author: - req.user?.collection === 'campaignUser' - ? req.user.id - : (update.author ?? null), + author: req.user?.collection === 'campaignUser' ? req.user.id : (update.author ?? null), createdAt: new Date().toISOString(), } }) } - if (operation === 'update' && req.user?.collection === 'campaignUser' && req.user.role === 'lideranca') { + const resultChanged = + (data.resultSummary !== undefined && + trimmedText(data.resultSummary) !== trimmedText(originalDoc?.resultSummary)) || + (data.resultMedia !== undefined && + JSON.stringify(relationshipIds(data.resultMedia)) !== + JSON.stringify(relationshipIds(originalDoc?.resultMedia))) + + if (resultChanged) { + data.resultRecordedAt = new Date().toISOString() + if (req.user?.collection === 'campaignUser') data.resultRecordedBy = req.user.id + } + + if ( + operation === 'update' && + req.user?.collection === 'campaignUser' && + req.user.role === 'leader' + ) { if (!isActionPlanMutationShortcut(req.context)) { const previous = (originalDoc ?? {}) as Record const merged = { ...previous, ...(data as Record) } @@ -241,10 +237,7 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) || (taskRecord.due ?? null) !== (previousTask.due ?? null) ) { - throw new APIError( - 'Lideranças só podem marcar tarefas como concluídas.', - 403, - ) + throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403) } } } else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) { @@ -264,7 +257,7 @@ export const ActionPlan: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'title', - defaultColumns: ['title', 'kind', 'status', 'startAt', 'updatedAt'], + defaultColumns: ['title', 'kind', 'status', 'plaza', 'startAt', 'updatedAt'], }, access: { create: canCreateActionPlan, @@ -275,9 +268,8 @@ export const ActionPlan: CollectionConfig = { hooks: { beforeValidate: [ setCanonicalActionPlanSlug, - validateActionPlanTerritory, validateActionPlanSchedule, - validateActionPlanCoordinators, + validateActionPlanAdvisors, ], beforeChange: [deriveActionPlanFields], }, @@ -333,6 +325,16 @@ export const ActionPlan: CollectionConfig = { label: 'Descrição', maxLength: 4000, }, + { + name: 'deputyPresent', + type: 'checkbox', + label: 'Deputado presente', + defaultValue: false, + index: true, + admin: { + description: 'Marque quando o deputado Jorge Solla estiver presente na ação.', + }, + }, { name: 'startAt', type: 'date', @@ -365,65 +367,39 @@ export const ActionPlan: CollectionConfig = { }, }, { - name: 'regions', - type: 'text', - label: 'Territórios de identidade', - hasMany: true, + name: 'plaza', + type: 'relationship', + relationTo: 'plaza', + label: 'Praça', + required: true, index: true, - maxRows: MAX_NUCLEUS_REGIONS, - validate: (value: unknown) => { - if (value === null || value === undefined) return true - if (!Array.isArray(value)) { - return 'Selecione um território de identidade válido da Bahia.' - } - return value.every( - (item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()), - ) - ? true - : 'Selecione um território de identidade válido da Bahia.' - }, - }, - { - name: 'cities', - type: 'text', - label: 'Municípios', - hasMany: true, - maxLength: 120, - index: true, - maxRows: MAX_NUCLEUS_CITIES, - }, - { - name: 'neighborhoods', - type: 'text', - label: 'Bairros', - hasMany: true, - maxLength: 160, - maxRows: MAX_NUCLEUS_NEIGHBORHOODS, }, { name: 'locality', type: 'text', - label: 'Localidade', + label: 'Local (bairro, endereço ou referência)', maxLength: 160, }, { - name: 'territoryNotes', - type: 'textarea', - label: 'Observações do território', - maxLength: 2000, + name: 'organizations', + type: 'relationship', + relationTo: 'organization', + label: 'Organizações apoiadoras', + hasMany: true, + index: true, }, { - name: 'coordinators', + name: 'advisors', type: 'relationship', relationTo: 'campaignUser', - label: 'Coordenadores', + label: 'Assessores responsáveis', hasMany: true, index: true, access: { - create: canCreateActionPlanCoordinators, - update: canManageActionPlanCoordinators, + create: canCreateActionPlanAdvisors, + update: canManageActionPlanAdvisors, }, - filterOptions: eligibleNucleusCoordinatorWhere, + filterOptions: eligibleCampaignStaffWhere, }, { name: 'responsible', @@ -562,6 +538,56 @@ export const ActionPlan: CollectionConfig = { }, ], }, + { + name: 'resultSummary', + type: 'textarea', + label: 'Resultado da ação', + maxLength: 6000, + access: { + create: canSetActionPlanStatus, + update: canSetActionPlanStatus, + }, + admin: { + description: + 'O que aconteceu, público, aprendizados. Vira base para futuras atividades na mesma Praça.', + }, + }, + { + name: 'resultMedia', + type: 'upload', + relationTo: 'media', + label: 'Fotos e vídeos do resultado', + hasMany: true, + access: { + create: canSetActionPlanStatus, + update: canSetActionPlanStatus, + }, + }, + { + name: 'resultRecordedBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Resultado registrado por', + admin: { + readOnly: true, + }, + access: { + create: canSetActionPlanSystemField, + update: canSetActionPlanSystemField, + }, + }, + { + name: 'resultRecordedAt', + type: 'date', + label: 'Resultado registrado em', + admin: { + readOnly: true, + }, + access: { + create: canSetActionPlanSystemField, + update: canSetActionPlanSystemField, + }, + }, { name: 'createdBy', type: 'relationship', diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts new file mode 100644 index 00000000..9fde58f6 --- /dev/null +++ b/src/collections/CampaignDemand.ts @@ -0,0 +1,382 @@ +import type { + CollectionBeforeChangeHook, + CollectionBeforeValidateHook, + CollectionConfig, +} from 'payload' +import { APIError } from 'payload' + +import { + campaignDemandKindLabels, + campaignDemandKinds, + campaignDemandStatusLabels, + campaignDemandStatuses, + campaignDemandTransitions, + type CampaignDemandStatus, +} from '@/lib/schemas/campaignDemand' +import { + canCreateCampaignDemand, + canDeleteCampaignDemand, + canManageCampaignStaffField, + canReadCampaignDemand, + canReadCampaignStaffField, + canSetCampaignSystemField, + canUpdateCampaignDemand, + getOwnEngagedLeadership, +} from '@/utilities/campaignAccess' +import { relationshipId } from '@/utilities/relationship' +import { slugify } from '@/utilities/slug' + +const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') + +const DEMAND_KIND_OPTIONS = campaignDemandKinds.map((value) => ({ + value, + label: campaignDemandKindLabels[value], +})) + +const DEMAND_STATUS_OPTIONS = campaignDemandStatuses.map((value) => ({ + value, + label: campaignDemandStatusLabels[value], +})) + +const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => { + if (!data) return data + const title = trimmedText(data.title ?? originalDoc?.title) + const slug = slugify(title) + if (!slug) { + throw new APIError('Informe um título com letras ou números.', 400) + } + data.title = title + data.slug = operation === 'create' ? slug : originalDoc?.slug + return data +} + +const isDemandStatus = (value: unknown): value is CampaignDemandStatus => + typeof value === 'string' && (campaignDemandStatuses as readonly string[]).includes(value) + +/** + * Workflow + authorship rules: + * - a leader's demand is linked to their own leadership on create; + * - a leader may edit only title/description/kind while the demand is "aberta"; + * - status moves follow campaignDemandTransitions; decisions on "escalada" + * demands are coordinator-only; + * - every status move appends a server-derived statusHistory entry. + */ +const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({ + data, + operation, + originalDoc, + req, +}) => { + const actor = req.user?.collection === 'campaignUser' ? req.user : null + const isLeaderActor = actor?.role === 'leader' + + if (operation === 'create') { + if (actor) data.createdBy = actor.id + + if (isLeaderActor) { + const ownLeadership = await getOwnEngagedLeadership(req, actor) + if (!ownLeadership) { + throw new APIError('Somente lideranças engajadas podem abrir demandas.', 403) + } + data.leadership = ownLeadership.id + data.status = 'aberta' + } + + const initialStatus = isDemandStatus(data.status) ? data.status : 'aberta' + data.status = initialStatus + data.statusHistory = [ + { + status: initialStatus, + note: trimmedText(data.decisionNote) || null, + author: actor?.id ?? null, + createdAt: new Date().toISOString(), + }, + ] + + return data + } + + const previousStatus: CampaignDemandStatus = isDemandStatus(originalDoc?.status) + ? originalDoc.status + : 'aberta' + const rawNextStatus: unknown = data.status === undefined ? previousStatus : data.status + + if (isLeaderActor) { + if (previousStatus !== 'aberta') { + throw new APIError('A demanda já está em análise e não pode mais ser editada.', 409) + } + if (data.status !== undefined && data.status !== previousStatus) { + throw new APIError('Lideranças não alteram o status da demanda.', 403) + } + const previousPlaza = relationshipId(originalDoc?.plaza) + if (data.plaza !== undefined && relationshipId(data.plaza) !== previousPlaza) { + throw new APIError('A Praça da demanda não pode ser alterada pela liderança.', 403) + } + if ( + data.leadership !== undefined && + relationshipId(data.leadership) !== relationshipId(originalDoc?.leadership) + ) { + throw new APIError('A liderança da demanda não pode ser alterada.', 403) + } + } + + if (!isDemandStatus(rawNextStatus)) { + throw new APIError('Status de demanda inválido.', 400) + } + const nextStatus: CampaignDemandStatus = rawNextStatus + + if (nextStatus !== previousStatus) { + const allowed = campaignDemandTransitions[previousStatus] ?? [] + if (!allowed.includes(nextStatus)) { + throw new APIError( + `Transição de status inválida: ${campaignDemandStatusLabels[previousStatus]} → ${campaignDemandStatusLabels[nextStatus]}.`, + 409, + ) + } + + if ( + previousStatus === 'escalada' && + (nextStatus === 'aprovada' || nextStatus === 'rejeitada') && + actor && + actor.role !== 'coordinator' + ) { + throw new APIError('Demandas escaladas são decididas pelo Coordenador Geral.', 403) + } + + const history = Array.isArray(originalDoc?.statusHistory) ? [...originalDoc.statusHistory] : [] + history.push({ + status: nextStatus, + note: trimmedText(data.decisionNote ?? originalDoc?.decisionNote) || null, + author: actor?.id ?? null, + createdAt: new Date().toISOString(), + }) + data.statusHistory = history + + if (nextStatus === 'aprovada' || nextStatus === 'rejeitada') { + data.decidedBy = actor?.id ?? null + data.decidedAt = new Date().toISOString() + } + } else if (data.statusHistory !== undefined) { + // History is append-only and server-derived. + data.statusHistory = originalDoc?.statusHistory ?? [] + } + + return data +} + +export const CampaignDemand: CollectionConfig = { + slug: 'campaignDemand', + labels: { + singular: 'Demanda', + plural: 'Demandas', + }, + admin: { + group: 'Campanha', + useAsTitle: 'title', + defaultColumns: ['title', 'kind', 'plaza', 'status', 'updatedAt'], + description: + 'Necessidades operacionais da campanha. Custo e comprovantes são controle interno — não substituem a prestação de contas oficial (SPCE/TSE).', + }, + access: { + create: canCreateCampaignDemand, + read: canReadCampaignDemand, + update: canUpdateCampaignDemand, + delete: canDeleteCampaignDemand, + }, + hooks: { + beforeValidate: [setCanonicalDemandSlug], + beforeChange: [enforceDemandWorkflow], + }, + fields: [ + { + name: 'title', + type: 'text', + label: 'Título', + required: true, + minLength: 2, + maxLength: 160, + index: true, + }, + { + name: 'slug', + type: 'text', + label: 'Slug', + required: true, + unique: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'kind', + type: 'select', + label: 'Tipo', + required: true, + index: true, + options: [...DEMAND_KIND_OPTIONS], + }, + { + name: 'description', + type: 'textarea', + label: 'Descrição', + maxLength: 4000, + }, + { + name: 'plaza', + type: 'relationship', + relationTo: 'plaza', + label: 'Praça', + required: true, + index: true, + }, + { + name: 'actionPlan', + type: 'relationship', + relationTo: 'actionPlan', + label: 'Plano de ação', + index: true, + }, + { + name: 'leadership', + type: 'relationship', + relationTo: 'leadership', + label: 'Liderança solicitante', + index: true, + }, + { + name: 'status', + type: 'select', + label: 'Status', + required: true, + defaultValue: 'aberta', + index: true, + access: { + create: canManageCampaignStaffField, + update: canManageCampaignStaffField, + }, + options: [...DEMAND_STATUS_OPTIONS], + }, + { + name: 'decisionNote', + type: 'textarea', + label: 'Nota da decisão', + maxLength: 2000, + access: { + create: canManageCampaignStaffField, + update: canManageCampaignStaffField, + }, + }, + { + name: 'decidedBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Decidida por', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'decidedAt', + type: 'date', + label: 'Decidida em', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'cost', + type: 'number', + label: 'Custo estimado (R$)', + min: 0, + access: { + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + admin: { + description: 'Controle interno de gastos. Não substitui a prestação de contas oficial.', + }, + }, + { + name: 'receipts', + type: 'upload', + relationTo: 'media', + label: 'Comprovantes', + hasMany: true, + access: { + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + admin: { + description: + 'Documentos fiscais podem conter CPF/CNPJ — acesso restrito ao staff. Não substitui o SPCE.', + }, + }, + { + name: 'statusHistory', + type: 'array', + label: 'Histórico de status', + access: { + read: canReadCampaignStaffField, + update: canSetCampaignSystemField, + }, + admin: { + readOnly: true, + }, + fields: [ + { + name: 'status', + type: 'select', + label: 'Status', + required: true, + options: [...DEMAND_STATUS_OPTIONS], + }, + { + name: 'note', + type: 'textarea', + label: 'Nota', + maxLength: 2000, + }, + { + name: 'author', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Autor', + }, + { + name: 'createdAt', + type: 'date', + label: 'Registrado em', + }, + ], + }, + { + name: 'createdBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Criado por', + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + ], +} diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index 2b0c8f7f..e990c72b 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -1,8 +1,8 @@ import type { CampaignUser as CampaignUserDocument } from '@/payload-types' import { canCreateCampaignUserPhone, - canManageCampaignUsers, canManageCampaignUserRole, + canManageCampaignUsers, canReadCampaignUserIdentity, canReadCampaignUserPhone, canReadCampaignUsers, @@ -41,7 +41,7 @@ const removePrivateAuthFields: CollectionAfterReadHook = a return doc } -const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook = async ({ +const preventAssignedAdvisorDowngrade: CollectionBeforeChangeHook = async ({ data, operation, originalDoc, @@ -50,27 +50,27 @@ const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook 0) { + if (assignedPlaza.totalDocs > 0) { throw new APIError( - 'Remova ou substitua este usuário da coordenação de todos os núcleos antes de alterar o papel para liderança.', + 'Remova ou substitua este usuário da assessoria de todas as Praças antes de alterar o papel para liderança.', 409, ) } @@ -78,12 +78,9 @@ const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook = async ({ - data, - operation, - originalDoc, - req, -}) => { +const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook< + CampaignUserDocument +> = async ({ data, operation, originalDoc, req }) => { if (operation !== 'update' || !originalDoc || req.user?.collection !== 'campaignUser') { return data } @@ -148,7 +145,7 @@ export const CampaignUser: CollectionConfig = { delete: canManageCampaignUsers, }, hooks: { - beforeChange: [preventAssignedCoordinatorDowngrade, preventSelfServicePrivilegedFields], + beforeChange: [preventAssignedAdvisorDowngrade, preventSelfServicePrivilegedFields], afterRead: [removePrivateAuthFields], }, fields: [ @@ -173,12 +170,12 @@ export const CampaignUser: CollectionConfig = { type: 'select', label: 'Papel', required: true, - defaultValue: 'lideranca', + defaultValue: 'leader', saveToJWT: true, options: [ - { label: 'Coordenação geral', value: 'geral' }, - { label: 'Coordenador', value: 'coordenador' }, - { label: 'Liderança', value: 'lideranca' }, + { label: 'Coordenador Geral', value: 'coordinator' }, + { label: 'Assessor', value: 'advisor' }, + { label: 'Liderança', value: 'leader' }, ], access: { update: canManageCampaignUserRole, diff --git a/src/collections/ElectoralNucleus.ts b/src/collections/ElectoralNucleus.ts deleted file mode 100644 index c1da2ea7..00000000 --- a/src/collections/ElectoralNucleus.ts +++ /dev/null @@ -1,671 +0,0 @@ -import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' -import { APIError } from 'payload' - -import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories' -import { - MAX_NUCLEUS_CITIES, - MAX_NUCLEUS_NEIGHBORHOODS, - MAX_NUCLEUS_REGIONS, -} from '@/lib/schemas/nucleus' -import { - canCreateElectoralNucleus, - canCreateNucleusCoordinators, - canDeleteElectoralNucleus, - canManageNucleusCoordinators, - canManageNucleusLifecycle, - canReadLeadershipInternal, - canReadElectoralNucleus, - canSetDerivedNucleusField, - canUpdateElectoralNucleus, -} from '@/utilities/campaignAccess' -import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation' -import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions' -import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock' -import { relationshipId } from '@/utilities/relationship' -import { slugify } from '@/utilities/slug' -import { - getVoteGoalsOrderViolation, - VOTE_GOALS_ORDER_ERROR_MESSAGE, - type VoteGoalsFields, -} from '@/utilities/voteGoals' - -const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') - -const voteGoalNumber = (value: unknown): number | null | undefined => { - if (value === null || value === undefined || value === '') return null - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { - throw new APIError('Cada meta de votos deve ser um número inteiro não negativo.', 400) - } - return Math.trunc(value) -} - -const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => { - if (!data) return data - - const mergedGoals = - data.voteGoals !== undefined - ? { ...(originalDoc?.voteGoals ?? {}), ...(data.voteGoals as object) } - : operation === 'update' - ? originalDoc?.voteGoals - : undefined - - if (!mergedGoals || typeof mergedGoals !== 'object') return data - - const good = voteGoalNumber((mergedGoals as VoteGoalsFields).good) - const regular = voteGoalNumber((mergedGoals as VoteGoalsFields).regular) - const minimum = voteGoalNumber((mergedGoals as VoteGoalsFields).minimum) - - if (getVoteGoalsOrderViolation({ good, regular, minimum })) { - throw new APIError(VOTE_GOALS_ORDER_ERROR_MESSAGE, 400) - } - - if (data.voteGoals && typeof data.voteGoals === 'object') { - data.voteGoals = { - good: - (data.voteGoals as { good?: unknown }).good === undefined - ? (originalDoc?.voteGoals?.good ?? null) - : good, - regular: - (data.voteGoals as { regular?: unknown }).regular === undefined - ? (originalDoc?.voteGoals?.regular ?? null) - : regular, - minimum: - (data.voteGoals as { minimum?: unknown }).minimum === undefined - ? (originalDoc?.voteGoals?.minimum ?? null) - : minimum, - } - } - - return data -} - -const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = (args) => { - const validateTerritory = createCampaignTerritoryValidationHook({ entityLabel: 'núcleo' }) - const data = validateTerritory(args) - if (!data) return data - - const nextData = args.operation === 'update' ? { ...args.originalDoc, ...data } : data - const zones = Array.isArray(nextData.tseZones) ? nextData.tseZones : [] - const zoneNumbers = zones.map((zone: { zoneNumber?: unknown }) => zone?.zoneNumber) - - if (new Set(zoneNumbers).size !== zoneNumbers.length) { - throw new APIError('Cada Zona TSE deve aparecer apenas uma vez.', 400) - } - - return data -} - -const setCanonicalNucleusSlug: CollectionBeforeValidateHook = ({ - data, - operation, - originalDoc, -}) => { - if (!data) return data - const name = trimmedText(data.name ?? originalDoc?.name) - const slug = slugify(name) - if (!slug) { - throw new APIError('Informe um nome com letras ou números.', 400) - } - if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) { - throw new APIError('O nome do núcleo não pode ser alterado após a criação.', 409) - } - data.name = name - data.slug = operation === 'create' ? slug : originalDoc?.slug - return data -} - -const validateNucleusCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => { - if (!data) return data - - if (data.coordinators === undefined) return data - const coordinatorValues = data.coordinators - const coordinatorIDs = Array.isArray(coordinatorValues) - ? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))] - : [] - - if (coordinatorIDs.length === 0) return data - - const eligibleCoordinators = await req.payload.find({ - collection: 'campaignUser', - depth: 0, - pagination: false, - where: { - and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere], - }, - select: { name: true }, - overrideAccess: true, - req, - }) - - if (eligibleCoordinators.docs.length !== coordinatorIDs.length) { - throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400) - } - - return data -} - -export const ElectoralNucleus: CollectionConfig = { - slug: 'electoralNucleus', - labels: { - singular: 'Núcleo eleitoral', - plural: 'Núcleos eleitorais', - }, - admin: { - group: 'Campanha', - useAsTitle: 'name', - defaultColumns: ['name', 'status', 'regions', 'cities', 'updatedAt'], - }, - access: { - create: canCreateElectoralNucleus, - read: canReadElectoralNucleus, - update: canUpdateElectoralNucleus, - delete: canDeleteElectoralNucleus, - }, - hooks: { - beforeValidate: [ - setCanonicalNucleusSlug, - validateNucleusTerritoryAndZones, - validateNucleusCoordinators, - validateVoteGoals, - ], - beforeChange: [ - ({ data, operation, req }) => { - if (operation === 'create' && req.user?.collection === 'campaignUser') { - data.createdBy = req.user.id - } - - return data - }, - async ({ data, operation, originalDoc, req }) => { - if (data.primaryContact === undefined || data.primaryContact === null) return data - - const contact = relationshipId(data.primaryContact) - const nucleus = relationshipId(originalDoc?.id) - if (!contact || operation !== 'update' || !nucleus) { - throw new APIError( - 'Escolha o contato principal entre as lideranças engajadas após criar o núcleo.', - 400, - ) - } - - await acquirePrimaryContactInvariantLocks(req, [nucleus]) - const engagedLeadership = await req.payload.find({ - collection: 'leadership', - where: { - and: [ - { nucleus: { equals: nucleus } }, - { contact: { equals: contact } }, - { supportStatus: { equals: 'engajado' } }, - ], - }, - depth: 0, - limit: 1, - overrideAccess: true, - req, - }) - - if (engagedLeadership.totalDocs === 0) { - throw new APIError( - 'O contato principal deve ser uma liderança engajada neste núcleo.', - 409, - ) - } - - return data - }, - ], - }, - fields: [ - { - name: 'name', - type: 'text', - label: 'Nome', - required: true, - minLength: 2, - maxLength: 160, - index: true, - }, - { - name: 'slug', - type: 'text', - label: 'Slug', - required: true, - unique: true, - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'status', - type: 'select', - label: 'Status', - required: true, - defaultValue: 'ativo', - index: true, - access: { - create: canSetDerivedNucleusField, - update: canManageNucleusLifecycle, - }, - options: [ - { label: 'Ativo', value: 'ativo' }, - { label: 'Arquivado', value: 'arquivado' }, - ], - }, - { - name: 'coordinators', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Coordenadores', - hasMany: true, - index: true, - access: { - create: canCreateNucleusCoordinators, - update: canManageNucleusCoordinators, - }, - filterOptions: eligibleNucleusCoordinatorWhere, - }, - { - name: 'regions', - type: 'text', - label: 'Territórios de identidade', - hasMany: true, - index: true, - maxRows: MAX_NUCLEUS_REGIONS, - validate: (value: unknown) => { - if (value === null || value === undefined) return true - if (!Array.isArray(value)) { - return 'Selecione um território de identidade válido da Bahia.' - } - return value.every( - (item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()), - ) - ? true - : 'Selecione um território de identidade válido da Bahia.' - }, - }, - { - name: 'cities', - type: 'text', - label: 'Municípios', - hasMany: true, - maxLength: 120, - index: true, - maxRows: MAX_NUCLEUS_CITIES, - }, - { - name: 'neighborhoods', - type: 'text', - label: 'Bairros', - hasMany: true, - maxLength: 160, - maxRows: MAX_NUCLEUS_NEIGHBORHOODS, - }, - { - name: 'locality', - type: 'text', - label: 'Localidade', - maxLength: 160, - }, - { - name: 'territoryNotes', - type: 'textarea', - label: 'Observações do território', - maxLength: 2000, - }, - { - name: 'organizationKind', - type: 'select', - label: 'Natureza organizativa', - required: true, - defaultValue: 'territorial', - index: true, - options: [ - { label: 'Territorial', value: 'territorial' }, - { label: 'Associação', value: 'associacao' }, - { label: 'Sindicato', value: 'sindicato' }, - { label: 'Religioso', value: 'religioso' }, - { label: 'Movimento', value: 'movimento' }, - { label: 'Categoria profissional', value: 'categoria_profissional' }, - { label: 'Outro', value: 'outro' }, - ], - }, - { - name: 'organizationLabel', - type: 'text', - label: 'Nome da organização', - maxLength: 160, - }, - { - name: 'sectorKind', - type: 'select', - label: 'Setor', - index: true, - options: [ - { label: 'Rural', value: 'rural' }, - { label: 'Religioso', value: 'religioso' }, - { label: 'Sindical', value: 'sindical' }, - { label: 'Empresarial', value: 'empresarial' }, - { label: 'Juventude', value: 'juventude' }, - { label: 'Saúde', value: 'saude' }, - { label: 'Educação', value: 'educacao' }, - { label: 'Cultura', value: 'cultura' }, - { label: 'Outro', value: 'outro' }, - ], - }, - { - name: 'tseZones', - type: 'array', - label: 'Zonas TSE', - labels: { - singular: 'Zona TSE', - plural: 'Zonas TSE', - }, - fields: [ - { - name: 'zoneNumber', - type: 'number', - label: 'Número', - required: true, - min: 1, - max: 999, - validate: (value: unknown) => - typeof value === 'number' && Number.isInteger(value) - ? true - : 'O número da Zona TSE deve ser inteiro.', - }, - { - name: 'label', - type: 'text', - label: 'Descrição', - maxLength: 160, - }, - ], - }, - { - name: 'primaryContact', - type: 'relationship', - relationTo: 'contact', - label: 'Contato principal', - index: true, - }, - { - name: 'voterProfiles', - type: 'array', - label: 'Perfis do eleitorado', - labels: { - singular: 'Perfil do eleitorado', - plural: 'Perfis do eleitorado', - }, - fields: [ - { - name: 'label', - type: 'text', - label: 'Nome do perfil', - required: true, - maxLength: 120, - }, - { - name: 'ageRange', - type: 'text', - label: 'Faixa etária', - maxLength: 80, - }, - { - name: 'incomeBand', - type: 'text', - label: 'Faixa de renda', - maxLength: 80, - }, - { - name: 'occupation', - type: 'text', - label: 'Ocupação', - maxLength: 120, - }, - { - name: 'localTraits', - type: 'textarea', - label: 'Características locais', - maxLength: 500, - }, - { - name: 'notes', - type: 'textarea', - label: 'Observações', - maxLength: 1000, - }, - ], - }, - { - name: 'strengths', - type: 'array', - label: 'Forças', - fields: [ - { - name: 'text', - type: 'textarea', - label: 'Força', - required: true, - maxLength: 1000, - }, - ], - }, - { - name: 'risks', - type: 'array', - label: 'Riscos', - fields: [ - { - name: 'text', - type: 'textarea', - label: 'Risco', - required: true, - maxLength: 1000, - }, - ], - }, - { - name: 'dobradinhaNotes', - type: 'textarea', - label: 'Dobradinhas (notas)', - maxLength: 4000, - admin: { - description: - 'Quem dobra no território hoje e o estado da negociação. Distinto do bloco estruturado “Dobrada”.', - }, - }, - { - name: 'nextSteps', - type: 'textarea', - label: 'Encaminhamentos', - maxLength: 4000, - admin: { - description: 'Próximos passos operacionais para o núcleo.', - }, - }, - { - name: 'voteGoals', - type: 'group', - label: 'Metas de votos 2026', - fields: [ - { - name: 'good', - type: 'number', - label: 'Bom', - min: 0, - }, - { - name: 'regular', - type: 'number', - label: 'Regular', - min: 0, - }, - { - name: 'minimum', - type: 'number', - label: 'Mínimo', - min: 0, - }, - ], - }, - { - name: 'priority', - type: 'select', - label: 'Prioridade', - defaultValue: 'normal', - index: true, - options: [ - { label: 'Alta', value: 'alta' }, - { label: 'Normal', value: 'normal' }, - ], - }, - { - name: 'confirmedVoteEstimate', - type: 'number', - label: 'Estimativa de votos confirmada', - min: 0, - index: true, - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'confirmedVoteEstimateAt', - type: 'date', - label: 'Estimativa confirmada em', - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'confirmedVoteEstimateBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Estimativa confirmada por', - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'confirmationNote', - type: 'textarea', - label: 'Justificativa da confirmação', - maxLength: 1000, - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'proposedVoteEstimate', - type: 'number', - label: 'Sugestão de estimativa de votos', - min: 0, - index: true, - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'proposedVoteEstimateAt', - type: 'date', - label: 'Sugestão enviada em', - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'proposedVoteEstimateBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Sugestão enviada por', - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'proposedVoteEstimateVersion', - type: 'text', - label: 'Versão da sugestão de estimativa', - index: true, - admin: { - readOnly: true, - }, - access: { - read: canReadLeadershipInternal, - create: () => false, - update: () => false, - }, - }, - { - name: 'ticketAlliance', - type: 'group', - label: 'Dobrada', - fields: [ - { - name: 'partnerName', - type: 'text', - label: 'Nome da parceria', - maxLength: 120, - }, - { - name: 'office', - type: 'text', - label: 'Cargo', - maxLength: 120, - }, - { - name: 'isCampaignPartner', - type: 'checkbox', - label: 'Parceiro da campanha', - defaultValue: false, - }, - { - name: 'notes', - type: 'textarea', - label: 'Observações', - maxLength: 1000, - }, - ], - }, - { - name: 'lastUpdateAt', - type: 'date', - label: 'Última atualização', - index: true, - admin: { - readOnly: true, - description: 'Preenchido automaticamente quando o domínio de atualizações for ativado.', - }, - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - { - name: 'createdBy', - type: 'relationship', - relationTo: 'campaignUser', - label: 'Criado por', - index: true, - admin: { - readOnly: true, - }, - access: { - create: canSetDerivedNucleusField, - update: canSetDerivedNucleusField, - }, - }, - ], -} diff --git a/src/collections/Leadership.ts b/src/collections/Leadership.ts index a5d8d78f..f5fa5374 100644 --- a/src/collections/Leadership.ts +++ b/src/collections/Leadership.ts @@ -1,18 +1,42 @@ -import type { CollectionConfig } from 'payload' +import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' import { APIError } from 'payload' import { canCreateLeadership, canDeleteLeadership, + canManageCampaignStaffField, canManageLeadership, - canManageLeadershipInternal, + canReadCampaignStaffField, canReadLeadership, - canReadLeadershipInternal, canSetAdministrativeLeadershipField, } from '@/utilities/campaignAccess' -import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock' import { relationshipId } from '@/utilities/relationship' +const MAX_LEADERSHIP_PLAZAS = 30 + +const requireAtLeastOnePlaza: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => { + if (!data) return data + + const plazas = + data.plazas !== undefined + ? data.plazas + : operation === 'update' + ? originalDoc?.plazas + : undefined + const plazaIDs = (Array.isArray(plazas) ? plazas : []) + .map(relationshipId) + .filter((id): id is number => id !== null) + + if (plazaIDs.length === 0) { + throw new APIError('Vincule a liderança a pelo menos uma Praça.', 400) + } + if (new Set(plazaIDs).size !== plazaIDs.length) { + throw new APIError('Cada Praça deve aparecer apenas uma vez.', 400) + } + + return data +} + export const Leadership: CollectionConfig = { slug: 'leadership', labels: { @@ -22,7 +46,7 @@ export const Leadership: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'contact', - defaultColumns: ['contact', 'nucleus', 'sector', 'supportStatus', 'updatedAt'], + defaultColumns: ['contact', 'plazas', 'sector', 'supportStatus', 'updatedAt'], }, access: { create: canCreateLeadership, @@ -30,55 +54,14 @@ export const Leadership: CollectionConfig = { update: canManageLeadership, delete: canDeleteLeadership, }, - indexes: [ - { - fields: ['contact', 'nucleus'], - unique: true, - }, - ], hooks: { + beforeValidate: [requireAtLeastOnePlaza], beforeChange: [ - async ({ data, operation, originalDoc, req }) => { + ({ data, operation, req }) => { if (operation === 'create' && req.user?.collection === 'campaignUser') { data.createdBy = req.user.id } - if (operation !== 'update') return data - const previousContact = relationshipId(originalDoc?.contact) - const previousNucleus = relationshipId(originalDoc?.nucleus) - const nextContact = relationshipId(data.contact ?? originalDoc?.contact) - const nextNucleus = relationshipId(data.nucleus ?? originalDoc?.nucleus) - const nextStatus = data.supportStatus ?? originalDoc?.supportStatus - if (!previousContact || !previousNucleus || !nextContact || !nextNucleus) return data - - await acquirePrimaryContactInvariantLocks(req, [previousNucleus, nextNucleus]) - const remainsEligible = - nextStatus === 'engajado' && - nextContact === previousContact && - nextNucleus === previousNucleus - if (remainsEligible) return data - - const primaryContactUse = await req.payload.find({ - collection: 'electoralNucleus', - where: { - and: [ - { id: { equals: previousNucleus } }, - { primaryContact: { equals: previousContact } }, - ], - }, - depth: 0, - limit: 1, - overrideAccess: true, - req, - }) - - if (primaryContactUse.totalDocs > 0) { - throw new APIError( - 'Escolha outro contato principal antes de alterar o status desta liderança.', - 409, - ) - } - return data }, ], @@ -90,21 +73,29 @@ export const Leadership: CollectionConfig = { relationTo: 'contact', label: 'Contato', required: true, + unique: true, index: true, access: { update: canSetAdministrativeLeadershipField, }, }, { - name: 'nucleus', + name: 'plazas', type: 'relationship', - relationTo: 'electoralNucleus', - label: 'Núcleo eleitoral', + relationTo: 'plaza', + label: 'Praças', required: true, + hasMany: true, + index: true, + maxRows: MAX_LEADERSHIP_PLAZAS, + }, + { + name: 'organizations', + type: 'relationship', + relationTo: 'organization', + label: 'Organizações', + hasMany: true, index: true, - access: { - update: canSetAdministrativeLeadershipField, - }, }, { name: 'sector', @@ -138,9 +129,9 @@ export const Leadership: CollectionConfig = { defaultValue: 'a_abordar', index: true, access: { - create: canManageLeadershipInternal, - read: canReadLeadershipInternal, - update: canManageLeadershipInternal, + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, }, options: [ { label: 'Engajado', value: 'engajado' }, @@ -200,9 +191,9 @@ export const Leadership: CollectionConfig = { label: 'Observações internas', maxLength: 3000, access: { - create: canManageLeadershipInternal, - read: canReadLeadershipInternal, - update: canManageLeadershipInternal, + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, }, }, { @@ -211,9 +202,9 @@ export const Leadership: CollectionConfig = { label: 'Registro de consentimento externo', maxLength: 2000, access: { - create: canManageLeadershipInternal, - read: canReadLeadershipInternal, - update: canManageLeadershipInternal, + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, }, }, { @@ -227,7 +218,7 @@ export const Leadership: CollectionConfig = { }, access: { create: canSetAdministrativeLeadershipField, - read: canReadLeadershipInternal, + read: canReadCampaignStaffField, update: canSetAdministrativeLeadershipField, }, }, diff --git a/src/collections/Organization.ts b/src/collections/Organization.ts new file mode 100644 index 00000000..7265ef45 --- /dev/null +++ b/src/collections/Organization.ts @@ -0,0 +1,135 @@ +import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' +import { APIError } from 'payload' + +import { + canCreateOrganization, + canDeleteOrganization, + canManageOrganization, + canReadOrganization, + canSetCampaignSystemField, +} from '@/utilities/campaignAccess' +import { slugify } from '@/utilities/slug' + +const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') + +const setCanonicalOrganizationSlug: CollectionBeforeValidateHook = ({ + data, + operation, + originalDoc, +}) => { + if (!data) return data + const name = trimmedText(data.name ?? originalDoc?.name) + const slug = slugify(name) + if (!slug) { + throw new APIError('Informe um nome com letras ou números.', 400) + } + if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) { + throw new APIError('O nome da organização não pode ser alterado após a criação.', 409) + } + data.name = name + data.slug = operation === 'create' ? slug : originalDoc?.slug + return data +} + +export const Organization: CollectionConfig = { + slug: 'organization', + labels: { + singular: 'Organização', + plural: 'Organizações', + }, + admin: { + group: 'Campanha', + useAsTitle: 'name', + defaultColumns: ['name', 'kind', 'updatedAt'], + description: + 'Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e Planos de Ação apoiados.', + }, + access: { + create: canCreateOrganization, + read: canReadOrganization, + update: canManageOrganization, + delete: canDeleteOrganization, + }, + hooks: { + beforeValidate: [setCanonicalOrganizationSlug], + beforeChange: [ + ({ data, operation, req }) => { + if (operation === 'create' && req.user?.collection === 'campaignUser') { + data.createdBy = req.user.id + } + + return data + }, + ], + }, + fields: [ + { + name: 'name', + type: 'text', + label: 'Nome', + required: true, + minLength: 2, + maxLength: 160, + unique: true, + index: true, + }, + { + name: 'slug', + type: 'text', + label: 'Slug', + required: true, + unique: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'kind', + type: 'select', + label: 'Tipo', + required: true, + index: true, + options: [ + { label: 'Sindicato', value: 'sindicato' }, + { label: 'Associação', value: 'associacao' }, + { label: 'Religiosa', value: 'religioso' }, + { label: 'Movimento', value: 'movimento' }, + { label: 'Categoria profissional', value: 'categoria_profissional' }, + { label: 'Outro', value: 'outro' }, + ], + }, + { + name: 'plazas', + type: 'relationship', + relationTo: 'plaza', + label: 'Praças de atuação', + hasMany: true, + index: true, + }, + { + name: 'notes', + type: 'textarea', + label: 'Observações', + maxLength: 4000, + }, + { + name: 'createdBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Criado por', + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + ], +} diff --git a/src/collections/Plaza.ts b/src/collections/Plaza.ts new file mode 100644 index 00000000..e7d29b30 --- /dev/null +++ b/src/collections/Plaza.ts @@ -0,0 +1,462 @@ +import type { + CollectionBeforeChangeHook, + CollectionBeforeValidateHook, + CollectionConfig, +} from 'payload' +import { APIError } from 'payload' + +import { + canAssignPlazaAdvisors, + canCreatePlaza, + canDeletePlaza, + canManageCampaignStaffField, + canManagePlazaAdvisors, + canReadCampaignStaffField, + canReadPlaza, + canSetCampaignSystemField, + canUpdatePlaza, + eligibleCampaignStaffWhere, +} from '@/utilities/campaignAccess' +import { relationshipId } from '@/utilities/relationship' +import { + getVoteGoalsOrderViolation, + VOTE_GOALS_ORDER_ERROR_MESSAGE, + type VoteGoalsFields, +} from '@/utilities/voteGoals' + +const voteGoalNumber = (value: unknown): number | null | undefined => { + if (value === null || value === undefined || value === '') return null + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new APIError('Cada meta de votos deve ser um número inteiro não negativo.', 400) + } + return Math.trunc(value) +} + +const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => { + if (!data) return data + + const mergedGoals = + data.voteGoals !== undefined + ? { ...(originalDoc?.voteGoals ?? {}), ...(data.voteGoals as object) } + : operation === 'update' + ? originalDoc?.voteGoals + : undefined + + if (!mergedGoals || typeof mergedGoals !== 'object') return data + + const good = voteGoalNumber((mergedGoals as VoteGoalsFields).good) + const regular = voteGoalNumber((mergedGoals as VoteGoalsFields).regular) + const minimum = voteGoalNumber((mergedGoals as VoteGoalsFields).minimum) + + if (getVoteGoalsOrderViolation({ good, regular, minimum })) { + throw new APIError(VOTE_GOALS_ORDER_ERROR_MESSAGE, 400) + } + + if (data.voteGoals && typeof data.voteGoals === 'object') { + data.voteGoals = { + good: + (data.voteGoals as { good?: unknown }).good === undefined + ? (originalDoc?.voteGoals?.good ?? null) + : good, + regular: + (data.voteGoals as { regular?: unknown }).regular === undefined + ? (originalDoc?.voteGoals?.regular ?? null) + : regular, + minimum: + (data.voteGoals as { minimum?: unknown }).minimum === undefined + ? (originalDoc?.voteGoals?.minimum ?? null) + : minimum, + } + } + + return data +} + +const validatePlazaAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => { + if (!data || data.advisors === undefined) return data + + const advisorIDs = Array.isArray(data.advisors) + ? [...new Set(data.advisors.map(relationshipId).filter((id): id is number => id !== null))] + : [] + if (advisorIDs.length === 0) return data + + const eligibleAdvisors = await req.payload.find({ + collection: 'campaignUser', + depth: 0, + pagination: false, + where: { + and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere], + }, + select: { name: true }, + overrideAccess: true, + req, + }) + + if (eligibleAdvisors.docs.length !== advisorIDs.length) { + throw new APIError('Cada assessor deve ter papel de Coordenador Geral ou Assessor.', 400) + } + + return data +} + +const trendSnapshot = (value: unknown) => { + const trend = (value ?? {}) as { status?: unknown; note?: unknown } + return `${trend.status ?? ''}\u0000${typeof trend.note === 'string' ? trend.note.trim() : ''}` +} + +const derivePoliticalTrendAudit: CollectionBeforeChangeHook = ({ data, originalDoc, req }) => { + if (!data || data.politicalTrend === undefined) return data + + const next = { ...(originalDoc?.politicalTrend ?? {}), ...(data.politicalTrend as object) } + if (trendSnapshot(next) === trendSnapshot(originalDoc?.politicalTrend)) return data + + data.politicalTrend = { + ...next, + recordedBy: + req.user?.collection === 'campaignUser' + ? req.user.id + : (relationshipId((next as { recordedBy?: unknown }).recordedBy) ?? null), + recordedAt: new Date().toISOString(), + } + + return data +} + +export const Plaza: CollectionConfig = { + slug: 'plaza', + labels: { + singular: 'Praça', + plural: 'Praças', + }, + admin: { + group: 'Campanha', + useAsTitle: 'name', + defaultColumns: ['name', 'kind', 'region', 'priority', 'updatedAt'], + description: + 'As 436 Praças são pré-definidas (município, ou zona eleitoral em Salvador e Camaçari) e criadas por migração. A geografia não é editável.', + }, + access: { + create: canCreatePlaza, + read: canReadPlaza, + update: canUpdatePlaza, + delete: canDeletePlaza, + }, + hooks: { + beforeValidate: [validatePlazaAdvisors, validateVoteGoals], + beforeChange: [derivePoliticalTrendAudit], + }, + fields: [ + { + name: 'name', + type: 'text', + label: 'Nome', + required: true, + unique: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'slug', + type: 'text', + label: 'Slug', + required: true, + unique: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'kind', + type: 'select', + label: 'Tipo', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + options: [ + { label: 'Município', value: 'municipio' }, + { label: 'Zona eleitoral', value: 'zona' }, + ], + }, + { + name: 'city', + type: 'text', + label: 'Município', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'region', + type: 'text', + label: 'Território de identidade', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'ibgeCode', + type: 'text', + label: 'Código IBGE', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'tseCityCode', + type: 'text', + label: 'Código TSE do município', + required: true, + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'zoneNumber', + type: 'number', + label: 'Zona eleitoral', + min: 1, + max: 999, + index: true, + admin: { + readOnly: true, + description: 'Preenchido apenas para Praças de zona (Salvador e Camaçari).', + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'advisors', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Assessores', + hasMany: true, + index: true, + access: { + create: canAssignPlazaAdvisors, + update: canManagePlazaAdvisors, + }, + filterOptions: eligibleCampaignStaffWhere, + }, + { + name: 'priority', + type: 'select', + label: 'Prioridade', + defaultValue: 'normal', + index: true, + access: { + read: canReadCampaignStaffField, + create: canManageCampaignStaffField, + update: canManageCampaignStaffField, + }, + options: [ + { label: 'Alta', value: 'alta' }, + { label: 'Normal', value: 'normal' }, + ], + }, + { + name: 'voteGoals', + type: 'group', + label: 'Metas de votos 2026', + access: { + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + fields: [ + { + name: 'good', + type: 'number', + label: 'Bom', + min: 0, + }, + { + name: 'regular', + type: 'number', + label: 'Regular', + min: 0, + }, + { + name: 'minimum', + type: 'number', + label: 'Mínimo', + min: 0, + }, + ], + }, + { + name: 'politicalTrend', + type: 'group', + label: 'Tendência política', + admin: { + description: + 'Leitura de conjuntura registrada pela coordenação (alianças, prefeitos, disputas locais) — não é série numérica.', + }, + access: { + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + fields: [ + { + name: 'status', + type: 'select', + label: 'Tendência', + options: [ + { label: 'Favorável', value: 'favoravel' }, + { label: 'Neutra', value: 'neutra' }, + { label: 'Desfavorável', value: 'desfavoravel' }, + ], + }, + { + name: 'note', + type: 'textarea', + label: 'Justificativa', + maxLength: 2000, + }, + { + name: 'recordedBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Registrada por', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'recordedAt', + type: 'date', + label: 'Registrada em', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + ], + }, + { + name: 'strengths', + type: 'array', + label: 'Forças', + access: { + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + fields: [ + { + name: 'text', + type: 'textarea', + label: 'Força', + required: true, + maxLength: 1000, + }, + ], + }, + { + name: 'risks', + type: 'array', + label: 'Riscos', + access: { + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + fields: [ + { + name: 'text', + type: 'textarea', + label: 'Risco', + required: true, + maxLength: 1000, + }, + ], + }, + { + name: 'dobradinhaNotes', + type: 'textarea', + label: 'Dobradinhas (notas)', + maxLength: 4000, + access: { + read: canReadCampaignStaffField, + create: canManageCampaignStaffField, + update: canManageCampaignStaffField, + }, + admin: { + description: 'Quem dobra na Praça hoje e o estado da negociação.', + }, + }, + { + name: 'nextSteps', + type: 'textarea', + label: 'Encaminhamentos', + maxLength: 4000, + access: { + read: canReadCampaignStaffField, + create: canManageCampaignStaffField, + update: canManageCampaignStaffField, + }, + admin: { + description: 'Próximos passos operacionais para a Praça.', + }, + }, + { + name: 'lastUpdateAt', + type: 'date', + label: 'Última atualização', + index: true, + admin: { + readOnly: true, + description: 'Derivado automaticamente do feed de atualizações da Praça.', + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + ], +} diff --git a/src/collections/NucleusUpdate.ts b/src/collections/PlazaUpdate.ts similarity index 54% rename from src/collections/NucleusUpdate.ts rename to src/collections/PlazaUpdate.ts index 561da0f4..fd2f3388 100644 --- a/src/collections/NucleusUpdate.ts +++ b/src/collections/PlazaUpdate.ts @@ -10,20 +10,20 @@ import type { import { APIError } from 'payload' import { - canCreateNucleusUpdate, - canMutateNucleusUpdate, - canReadNucleusUpdate, - canSetNucleusUpdateAuthor, + canCreatePlazaUpdate, + canMutatePlazaUpdate, + canReadPlazaUpdate, + canSetPlazaUpdateAuthor, } from '@/utilities/campaignAccess' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' import { relationshipId } from '@/utilities/relationship' -const DERIVED_NUCLEUS_UPDATE_CONTEXT = 'nucleusUpdateDerivedField' +const DERIVED_PLAZA_UPDATE_CONTEXT = 'plazaUpdateDerivedField' const nonEmptyText = (value: unknown): boolean => typeof value === 'string' && value.trim().length > 0 -const validateNucleusUpdateKind: CollectionBeforeValidateHook = ({ +const validatePlazaUpdateKind: CollectionBeforeValidateHook = ({ data, operation, originalDoc, @@ -44,23 +44,23 @@ const validateNucleusUpdateKind: CollectionBeforeValidateHook = ({ return data } -const uniqueSortedNucleusIDs = (values: Array): number[] => +const uniqueSortedPlazaIDs = (values: Array): number[] => [...new Set(values.filter((value): value is number => value !== null))].sort( (left, right) => left - right, ) -const acquireNucleusUpdateLocks = (req: PayloadRequest, nucleusIDs: number[]) => +const acquirePlazaUpdateLocks = (req: PayloadRequest, plazaIDs: number[]) => acquireTextAdvisoryLocks( req.payload, req, - nucleusIDs.map((nucleusID) => `nucleus-updates:${nucleusID}`), + plazaIDs.map((plazaID) => `plaza-updates:${plazaID}`), ) -const recomputeNucleusLastUpdateAt = async (req: PayloadRequest, nucleusIDs: number[]) => { - for (const nucleusID of nucleusIDs) { +const recomputePlazaLastUpdateAt = async (req: PayloadRequest, plazaIDs: number[]) => { + for (const plazaID of plazaIDs) { const latest = await req.payload.find({ - collection: 'nucleusUpdate', - where: { nucleus: { equals: nucleusID } }, + collection: 'plazaUpdate', + where: { plaza: { equals: plazaID } }, depth: 0, limit: 1, sort: '-createdAt', @@ -69,118 +69,118 @@ const recomputeNucleusLastUpdateAt = async (req: PayloadRequest, nucleusIDs: num }) await req.payload.update({ - collection: 'electoralNucleus', - id: nucleusID, + collection: 'plaza', + id: plazaID, data: { lastUpdateAt: latest.docs[0]?.createdAt ?? null }, depth: 0, overrideAccess: true, context: { - [DERIVED_NUCLEUS_UPDATE_CONTEXT]: true, + [DERIVED_PLAZA_UPDATE_CONTEXT]: true, }, req, }) } } -const lockNucleiBeforeChange: CollectionBeforeChangeHook = async ({ +const lockPlazasBeforeChange: CollectionBeforeChangeHook = async ({ context, data, operation, originalDoc, req, }) => { - if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return data + if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return data if (operation === 'create' && req.user?.collection === 'campaignUser') { data.author = req.user.id } - const nucleusIDs = uniqueSortedNucleusIDs([ - relationshipId(originalDoc?.nucleus), - relationshipId(data.nucleus ?? originalDoc?.nucleus), + const plazaIDs = uniqueSortedPlazaIDs([ + relationshipId(originalDoc?.plaza), + relationshipId(data.plaza ?? originalDoc?.plaza), ]) - if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500) - await acquireNucleusUpdateLocks(req, nucleusIDs) + await acquirePlazaUpdateLocks(req, plazaIDs) return data } -const lockNucleusBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => { - if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return +const lockPlazaBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => { + if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return const doc = await req.payload.findByID({ - collection: 'nucleusUpdate', + collection: 'plazaUpdate', id, depth: 0, overrideAccess: true, req, }) - const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)]) - if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + const plazaIDs = uniqueSortedPlazaIDs([relationshipId(doc.plaza)]) + if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500) - await acquireNucleusUpdateLocks(req, nucleusIDs) + await acquirePlazaUpdateLocks(req, plazaIDs) } -const recomputeChangedNuclei: CollectionAfterChangeHook = async ({ +const recomputeChangedPlazas: CollectionAfterChangeHook = async ({ context, doc, previousDoc, req, }) => { - if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc + if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc - const nucleusIDs = uniqueSortedNucleusIDs([ - relationshipId(previousDoc?.nucleus), - relationshipId(doc.nucleus), + const plazaIDs = uniqueSortedPlazaIDs([ + relationshipId(previousDoc?.plaza), + relationshipId(doc.plaza), ]) - if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500) - await acquireNucleusUpdateLocks(req, nucleusIDs) - await recomputeNucleusLastUpdateAt(req, nucleusIDs) + await acquirePlazaUpdateLocks(req, plazaIDs) + await recomputePlazaLastUpdateAt(req, plazaIDs) return doc } -const recomputeDeletedNucleus: CollectionAfterDeleteHook = async ({ context, doc, req }) => { - if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc +const recomputeDeletedPlaza: CollectionAfterDeleteHook = async ({ context, doc, req }) => { + if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc - const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)]) - if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500) + const plazaIDs = uniqueSortedPlazaIDs([relationshipId(doc.plaza)]) + if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500) - await acquireNucleusUpdateLocks(req, nucleusIDs) - await recomputeNucleusLastUpdateAt(req, nucleusIDs) + await acquirePlazaUpdateLocks(req, plazaIDs) + await recomputePlazaLastUpdateAt(req, plazaIDs) return doc } -export const NucleusUpdate: CollectionConfig = { - slug: 'nucleusUpdate', +export const PlazaUpdate: CollectionConfig = { + slug: 'plazaUpdate', labels: { - singular: 'Atualização do núcleo', - plural: 'Atualizações dos núcleos', + singular: 'Atualização da Praça', + plural: 'Atualizações das Praças', }, admin: { group: 'Campanha', useAsTitle: 'kind', - defaultColumns: ['nucleus', 'author', 'kind', 'createdAt'], + defaultColumns: ['plaza', 'author', 'kind', 'createdAt'], }, access: { - create: canCreateNucleusUpdate, - read: canReadNucleusUpdate, - update: canMutateNucleusUpdate, - delete: canMutateNucleusUpdate, + create: canCreatePlazaUpdate, + read: canReadPlazaUpdate, + update: canMutatePlazaUpdate, + delete: canMutatePlazaUpdate, }, hooks: { - beforeValidate: [validateNucleusUpdateKind], - beforeChange: [lockNucleiBeforeChange], - beforeDelete: [lockNucleusBeforeDelete], - afterChange: [recomputeChangedNuclei], - afterDelete: [recomputeDeletedNucleus], + beforeValidate: [validatePlazaUpdateKind], + beforeChange: [lockPlazasBeforeChange], + beforeDelete: [lockPlazaBeforeDelete], + afterChange: [recomputeChangedPlazas], + afterDelete: [recomputeDeletedPlaza], }, fields: [ { - name: 'nucleus', + name: 'plaza', type: 'relationship', - relationTo: 'electoralNucleus', - label: 'Núcleo eleitoral', + relationTo: 'plaza', + label: 'Praça', required: true, index: true, }, @@ -195,8 +195,8 @@ export const NucleusUpdate: CollectionConfig = { readOnly: true, }, access: { - create: canSetNucleusUpdateAuthor, - update: canSetNucleusUpdateAuthor, + create: canSetPlazaUpdateAuthor, + update: canSetPlazaUpdateAuthor, }, }, { diff --git a/src/collections/Supporter.ts b/src/collections/Supporter.ts index da3a6081..d8705f4e 100644 --- a/src/collections/Supporter.ts +++ b/src/collections/Supporter.ts @@ -4,9 +4,9 @@ import { APIError } from 'payload' import { canCreateSupporter, canDeleteSupporter, - canManageLeadershipInternal, + canManageCampaignStaffField, canManageSupporter, - canReadLeadershipInternal, + canReadCampaignStaffField, canReadSupporter, canSetAdministrativeLeadershipField, } from '@/utilities/campaignAccess' @@ -21,7 +21,7 @@ export const Supporter: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'contact', - defaultColumns: ['contact', 'nucleus', 'voteIntention', 'source', 'updatedAt'], + defaultColumns: ['contact', 'plaza', 'voteIntention', 'source', 'updatedAt'], }, access: { create: canCreateSupporter, @@ -37,13 +37,13 @@ export const Supporter: CollectionConfig = { } const contactID = relationshipId(data.contact ?? originalDoc?.contact) - const nucleusID = relationshipId(data.nucleus ?? originalDoc?.nucleus) - if (!contactID || !nucleusID) return data + const plazaID = relationshipId(data.plaza ?? originalDoc?.plaza) + if (!contactID || !plazaID) return data const existingLeadership = await req.payload.find({ collection: 'leadership', where: { - and: [{ contact: { equals: contactID } }, { nucleus: { equals: nucleusID } }], + and: [{ contact: { equals: contactID } }, { plazas: { in: [plazaID] } }], }, depth: 0, limit: 1, @@ -54,7 +54,7 @@ export const Supporter: CollectionConfig = { if (existingLeadership.totalDocs > 0) { throw new APIError( - 'Este contato já é liderança neste núcleo e não pode ser cadastrado como apoiador.', + 'Este contato já é liderança nesta Praça e não pode ser cadastrado como apoiador.', 409, ) } @@ -76,13 +76,13 @@ export const Supporter: CollectionConfig = { }, }, { - name: 'nucleus', + name: 'plaza', type: 'relationship', - relationTo: 'electoralNucleus', - label: 'Núcleo eleitoral', + relationTo: 'plaza', + label: 'Praça', index: true, access: { - update: canManageLeadershipInternal, + update: canManageCampaignStaffField, }, }, { @@ -91,9 +91,9 @@ export const Supporter: CollectionConfig = { label: 'Intenção de voto', index: true, access: { - create: canManageLeadershipInternal, - read: canReadLeadershipInternal, - update: canManageLeadershipInternal, + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, }, options: [ { label: 'Certo', value: 'certo' }, @@ -193,9 +193,9 @@ export const Supporter: CollectionConfig = { label: 'Registro de consentimento externo', maxLength: 2000, access: { - create: canManageLeadershipInternal, - read: canReadLeadershipInternal, - update: canManageLeadershipInternal, + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, }, }, { @@ -204,9 +204,9 @@ export const Supporter: CollectionConfig = { label: 'Observações internas', maxLength: 3000, access: { - create: canManageLeadershipInternal, - read: canReadLeadershipInternal, - update: canManageLeadershipInternal, + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, }, }, { @@ -220,7 +220,7 @@ export const Supporter: CollectionConfig = { }, access: { create: canSetAdministrativeLeadershipField, - read: canReadLeadershipInternal, + read: canReadCampaignStaffField, update: canSetAdministrativeLeadershipField, }, }, diff --git a/src/collections/VotePledge.ts b/src/collections/VotePledge.ts new file mode 100644 index 00000000..2aaf4e82 --- /dev/null +++ b/src/collections/VotePledge.ts @@ -0,0 +1,214 @@ +import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload' +import { APIError } from 'payload' + +import { + canCreateVotePledge, + canDeleteVotePledge, + canManageCampaignStaffField, + canReadCampaignStaffField, + canReadVotePledge, + canSetCampaignSystemField, + canUpdateVotePledge, +} from '@/utilities/campaignAccess' +import { relationshipId } from '@/utilities/relationship' + +/** + * Vote pledge per leadership × plaza. The leader DECLARES how many votes they + * are bringing (`declaredVotes` — the only number they ever see); staff record + * their own ESTIMATE of the real value (`estimatedVotes`), which is never + * serialized to the leader (field access denies read). Plaza aggregates use + * `estimatedVotes ?? declaredVotes` on staff surfaces only. + */ + +const validatePledgeIntegrity: CollectionBeforeChangeHook = async ({ + data, + operation, + originalDoc, + req, +}) => { + const leadershipID = relationshipId(data.leadership ?? originalDoc?.leadership) + const plazaID = relationshipId(data.plaza ?? originalDoc?.plaza) + if (!leadershipID || !plazaID) { + throw new APIError('Informe a liderança e a Praça do compromisso de votos.', 400) + } + + if (operation === 'update') { + const previousLeadership = relationshipId(originalDoc?.leadership) + const previousPlaza = relationshipId(originalDoc?.plaza) + if ( + (data.leadership !== undefined && leadershipID !== previousLeadership) || + (data.plaza !== undefined && plazaID !== previousPlaza) + ) { + throw new APIError('A liderança e a Praça do compromisso não podem ser alteradas.', 409) + } + } + + const leadership = await req.payload.findByID({ + collection: 'leadership', + id: leadershipID, + depth: 0, + overrideAccess: true, + req, + }) + const linkedPlazaIDs = (Array.isArray(leadership.plazas) ? leadership.plazas : []) + .map(relationshipId) + .filter((id): id is number => id !== null) + + if (!linkedPlazaIDs.includes(plazaID)) { + throw new APIError('A liderança precisa estar vinculada à Praça para declarar votos nela.', 409) + } + + return data +} + +const deriveVotePledgeAudit: CollectionBeforeChangeHook = ({ data, originalDoc, req }) => { + const actorID = req.user?.collection === 'campaignUser' ? req.user.id : null + + if (data.declaredVotes !== undefined && data.declaredVotes !== originalDoc?.declaredVotes) { + data.declaredAt = new Date().toISOString() + if (actorID) data.declaredBy = actorID + } + + const estimateChanged = + (data.estimatedVotes !== undefined && data.estimatedVotes !== originalDoc?.estimatedVotes) || + (data.estimateNote !== undefined && data.estimateNote !== originalDoc?.estimateNote) + + if (estimateChanged) { + data.estimatedAt = new Date().toISOString() + if (actorID) data.estimatedBy = actorID + } + + return data +} + +export const VotePledge: CollectionConfig = { + slug: 'votePledge', + labels: { + singular: 'Votos declarados', + plural: 'Votos declarados', + }, + admin: { + group: 'Campanha', + useAsTitle: 'id', + defaultColumns: ['leadership', 'plaza', 'declaredVotes', 'estimatedVotes', 'updatedAt'], + }, + access: { + create: canCreateVotePledge, + read: canReadVotePledge, + update: canUpdateVotePledge, + delete: canDeleteVotePledge, + }, + indexes: [ + { + fields: ['leadership', 'plaza'], + unique: true, + }, + ], + hooks: { + beforeChange: [validatePledgeIntegrity, deriveVotePledgeAudit], + }, + fields: [ + { + name: 'leadership', + type: 'relationship', + relationTo: 'leadership', + label: 'Liderança', + required: true, + index: true, + }, + { + name: 'plaza', + type: 'relationship', + relationTo: 'plaza', + label: 'Praça', + required: true, + index: true, + }, + { + name: 'declaredVotes', + type: 'number', + label: 'Votos declarados pela liderança', + required: true, + min: 0, + index: true, + }, + { + name: 'declaredAt', + type: 'date', + label: 'Declarado em', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'declaredBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Declarado por', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'estimatedVotes', + type: 'number', + label: 'Votos estimados pelo assessor', + min: 0, + index: true, + admin: { + description: 'Estimativa interna do valor real. A liderança nunca vê este número.', + }, + access: { + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + }, + { + name: 'estimateNote', + type: 'textarea', + label: 'Justificativa da estimativa', + maxLength: 1000, + access: { + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + }, + { + name: 'estimatedBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Estimado por', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + read: canReadCampaignStaffField, + update: canSetCampaignSystemField, + }, + }, + { + name: 'estimatedAt', + type: 'date', + label: 'Estimado em', + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + read: canReadCampaignStaffField, + update: canSetCampaignSystemField, + }, + }, + ], +} diff --git a/src/components/campaign/ActionPlanCard.tsx b/src/components/campaign/ActionPlanCard.tsx index c81f5ef5..1816d1c2 100644 --- a/src/components/campaign/ActionPlanCard.tsx +++ b/src/components/campaign/ActionPlanCard.tsx @@ -8,10 +8,8 @@ import { actionPlanKindLabels } from '@/lib/schemas/actionPlan' import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels' -const formatWhen = (plan: ActionPlanListViewModel): string => { - if (!plan.startAt) return 'Data a definir' - return plan.city ? `${formatBahiaDateTimeLabel(plan.startAt)} · ${plan.city}` : formatBahiaDateTimeLabel(plan.startAt) -} +const formatWhen = (plan: ActionPlanListViewModel): string => + plan.startAt ? formatBahiaDateTimeLabel(plan.startAt) : 'Data a definir' export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => ( @@ -19,6 +17,7 @@ export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
{actionPlanKindLabels[plan.kind]} + {plan.deputyPresent ? Deputado presente : null}
(
- {plan.territoryLabel ? ( + {plan.locationLabel ? (
) : null}
diff --git a/src/components/campaign/ActionPlanFilters.tsx b/src/components/campaign/ActionPlanFilters.tsx index 96ae9848..b8515743 100644 --- a/src/components/campaign/ActionPlanFilters.tsx +++ b/src/components/campaign/ActionPlanFilters.tsx @@ -5,13 +5,12 @@ import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react' -import { StrictCombobox } from '@/components/campaign/StrictCombobox' +import type { RelationOption } from '@/components/campaign/RelationMultiSelect' import { Button } from '@/components/ui/button' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { cn } from '@/lib/utils' import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan' -import { territoryForCity } from '@/lib/bahiaTerritories' import { actionPlanTabLabels, actionPlanTabs, @@ -19,33 +18,31 @@ import { type ActionPlanListState, type ActionPlanTab, } from '@/utilities/actionPlanUi' -import { - municipalityComboboxOptions, - territoryComboboxOptions, -} from '@/utilities/territoryComboboxOptions' type FilterValues = { kind: string status: string - region: string - city: string + plaza: string } const valuesFromState = (state: ActionPlanListState): FilterValues => ({ kind: state.kind ?? '', status: state.status ?? '', - region: state.region ?? '', - city: state.city ?? '', + plaza: state.plaza ? String(state.plaza) : '', }) +const plazaIdFromValue = (value: string): number | undefined => { + const id = Number(value) + return Number.isInteger(id) && id > 0 ? id : undefined +} + const buildTabHref = (state: ActionPlanListState, tab: ActionPlanTab): string => { const params = buildActionPlanListSearchParams({ page: 1, tab, kind: state.kind, status: tab === 'todos' ? state.status : undefined, - region: state.region, - city: state.city, + plaza: state.plaza, }) const query = params.toString() return query ? `/campanha/planos?${query}` : '/campanha/planos' @@ -71,7 +68,13 @@ const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => ( ) -export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => { +export const ActionPlanFilters = ({ + state, + plazaOptions, +}: { + state: ActionPlanListState + plazaOptions: RelationOption[] +}) => { const router = useRouter() const pathname = usePathname() const initialValues = valuesFromState(state) @@ -88,8 +91,7 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => tab: state.tab, kind: nextValues.kind as ActionPlanListState['kind'], status: nextValues.status as ActionPlanListState['status'], - region: nextValues.region as ActionPlanListState['region'], - city: nextValues.city, + plaza: plazaIdFromValue(nextValues.plaza), }) const query = params.toString() startTransition(() => { @@ -99,21 +101,9 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind }) const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status }) + const updatePlaza = (plaza: string) => replaceValues({ ...valuesRef.current, plaza }) - const updateTerritory = (region: string) => { - const current = valuesRef.current - const city = current.city && region && territoryForCity(current.city) !== region ? '' : current.city - replaceValues({ ...current, region, city }) - } - - const updateCity = (city: string) => { - const current = valuesRef.current - const region = territoryForCity(city) ?? current.region - replaceValues({ ...current, city, region }) - } - - const clearFilters = () => - replaceValues({ kind: '', status: '', region: '', city: '' }) + const clearFilters = () => replaceValues({ kind: '', status: '', plaza: '' }) const hasFilters = Object.values(values).some(Boolean) @@ -152,7 +142,7 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) =>
@@ -190,22 +180,20 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => ) : null} - Território de identidade - - - - Município - + Praça + updatePlaza(event.target.value)} + className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" + > + Todas as Praças + {plazaOptions.map((option) => ( + + {option.name} + + ))} +
{hasFilters ? ( diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx index 72cd4a55..3c97e2f7 100644 --- a/src/components/campaign/ActionPlanForm.tsx +++ b/src/components/campaign/ActionPlanForm.tsx @@ -3,10 +3,13 @@ import { useActionState, useState } from 'react' import Link from 'next/link' +import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox' import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox' -import { LeadershipCombobox } from '@/components/campaign/LeadershipCombobox' import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields' -import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields' +import { + RelationMultiSelect, + type RelationOption, +} from '@/components/campaign/RelationMultiSelect' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -25,12 +28,13 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import { Spinner } from '@/components/ui/Spinner' +import { isContactSearchQueryReady } from '@/lib/contactSearchQuery' import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan' import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions' import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels' import { fieldError } from '@/utilities/campaignFormFields' import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime' -import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions' +import type { EligibleAdvisorOption } from '@/utilities/plazaViewModels' export type ActionPlanFormState = { message?: string @@ -45,8 +49,10 @@ type ActionPlanFormAction = ( ) => Promise export type ActionPlanFormFieldsProps = { - coordinators?: NucleusCoordinatorOption[] - canManageCoordinators?: boolean + plazaOptions: RelationOption[] + organizationOptions: RelationOption[] + advisorOptions?: EligibleAdvisorOption[] + canManageAdvisors?: boolean plan?: ActionPlanFormViewModel fieldErrors?: Record submittedTitle?: string @@ -57,8 +63,10 @@ export type ActionPlanFormFieldsProps = { const editableStatuses = ['rascunho', 'planejado'] as const export const ActionPlanFormFields = ({ - coordinators = [], - canManageCoordinators = false, + plazaOptions, + organizationOptions, + advisorOptions = [], + canManageAdvisors = false, plan, fieldErrors = {}, submittedTitle, @@ -175,6 +183,12 @@ export const ActionPlanFormFields = ({ {errorFor('description')} ) : null} + + + + Deputado presente + + @@ -237,44 +251,43 @@ export const ActionPlanFormFields = ({ - Território + Onde - -
- - Localidade - - {errorFor('locality') ? ( - {errorFor('locality')} - ) : null} - -
- - Obrigatório: informe ao menos território de identidade, município ou localidade. - - - Observações do território -