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