merge main into B147; keep optional sinal body for v2 status

Align with B134 engagement write path; preserve B147 sinal empty-motivo
(collection + zod) required by the status-strip confirm rite.
This commit is contained in:
Cursor Agent 2026-08-02 23:48:29 +00:00
commit a2553e1f03
No known key found for this signature in database
15 changed files with 293 additions and 20 deletions

View file

@ -0,0 +1,64 @@
# Impl: Motivo opcional — tendência (wizard) + nível; remover voltar atrás do DB (B134)
Status: aprovado
Atualizado em: 2026-08-02
Issue: #288
Intenção: docs/plans/motivo-opcional-tendencia-e-nivel.md
Appetite restante: herdado (~1 dia eng)
## Leitura da intenção
- **Outcome:** staff grava tendência ou nível sem texto obrigatório; Motivo permanece opcional nos dois ritos; `reversalSignals` some da UI, write path e snapshots persistidos.
- **O que NÃO negociar:** histerese/override/choque triangulado intactos; `leader` lockdown; sem auto-save no nível; motivo vazio grava `null` (não preserva anterior); dados históricos de `reversalSignals` apagados do DB.
- **O que reavaliar:** não há coluna dedicada — `reversalSignals` vive só no JSON `allocation_decision.snapshot`; migration é data-only, sem alterar `AllocationDecision` config.
## Abordagem recomendada
```mermaid
flowchart LR
UI[Popover / wizard] --> schema[municipalityEngagementLevelSchema]
schema --> action[setMunicipalityEngagementLevelRecord]
action --> mun[municipality.levelNote nullable]
action --> ad[allocationDecision snapshot sem reversalSignals]
mig[migration SQL] --> ad
```
**Opções consideradas:** A remover só da UI/schema | B migration JSON strip + schema/UI/action
**Recomendação:** B — aceite exige apagar do banco, não só esconder.
**Rejeitadas:** A (órfãos no snapshot); tornar `rationale` opcional na collection (escopo desnecessário — string vazia basta quando motivo ausente).
### Componentes / mudanças
- **`municipalityEngagementLevelSchema`** (`src/lib/schemas/municipality.ts`): `note` → `trimmedNullableText`; remover `reversalSignals`.
- **`setMunicipalityEngagementLevelRecord`** (`actions/municipality.ts`): snapshot sem `reversalSignals`; `rationale: note ?? ''`.
- **`MunicipalityListLevelControl`**: um campo Motivo (opcional); `canSubmit` só exige movimento + override quando violações.
- **`WizardTrendNoteStep`**: remover `required`; label com “(opcional)”.
- **Migration:** `20260802_*_strip_engagement_reversal_signals` — `UPDATE allocation_decision SET snapshot = snapshot - 'reversalSignals' WHERE …`.
- **Access / Consent:** inalterado (`canManageMunicipalityEngagementLevel`).
### Dados → forma
- Tendência: schema já usa `trimmedNullableText` — só UI wizard tinha `required` HTML.
- Nível: `levelNote` nullable no município; `allocationDecision.rationale` string (vazia se sem motivo).
## Fases verificáveis
1. **Schema+server** — schema, action, migration, int test com motivo vazio + snapshot sem reversalSignals.
2. **UI** — labels “(opcional)”, remover segundo textarea.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Alterar copy do glossário `campaignIntelligenceConcepts` (prosa conceitual, não o campo).
- `rationale` required na collection — string vazia, não relaxar schema Payload.
## Riscos e mitigação
- **Rationale vazio rejeitado pelo Payload:** int test com `note` omitido; se falhar, usar `''` explicitamente (textarea required aceita vazio).
- **Migration idempotente:** operador JSONB `-` só onde chave existe; log de row count.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (int engagement level + migration unit)

View file

@ -0,0 +1,61 @@
# Impl: Sidebar — entrada para Organizações
Status: aprovado
Atualizado em: 2026-08-02
Issue: #313
Intenção: docs/plans/sidebar-organizacoes.md
Appetite restante: ~0,25 dia eng (um item de nav; sem migration)
## Leitura da intenção
- **Outcome:** Staff (coordenador, assessor, candidato) descobre `/campanha/organizacoes` pela sidebar desktop e Sheet mobile; liderança não vê o item; destino ativo em lista, detalhe e `/nova`.
- **O que NÃO negociar:** lockdown de `leader`; não redesenhar sidebar; não duplicar lista; href canônico `/campanha/organizacoes`.
- **O que reavaliar:** hipótese de importar `ORGANIZATIONS_LIST_PATH` — `nav.ts` usa literais (precedente Conceitos) para não puxar módulos ao client bundle; manter literal.
## Abordagem recomendada
```mermaid
flowchart LR
staffNav["staffNav + Organizações"] --> getCampaignNav
getCampaignNav --> CampaignSidebar
getCampaignNav --> MobileSheet["Sheet mobile"]
isCampaignNavActive --> Active["lista / slug / nova"]
```
**Opções consideradas:** A) item após Lideranças | B) após Dobradinhas | C) antes de Apoiadores
**Recomendação:** **A** — afinidade domínio orgs ↔ lideranças; alinhado à recomendação da intenção.
**Rejeitadas:** B/C — sem ganho de descoberta; A é o modelo mental acordado.
### Componentes / mudanças
- **`staffNav`** (`src/components/campaign/shell/nav.ts`): inserir `{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon }` logo após Lideranças; import `LandmarkIcon` de lucide-react (instituições — distinto de `HandshakeIcon` e `Building2` usado em município no dashboard).
- **Migration:** sem migration
- **Access / Consent:** nenhum — `getCampaignNav` já exclui `leader`; staff vê por default (mesmo que Lideranças/Demandas)
- **UI:** Impeccable B — encaixe no shell existente; `CampaignSidebar` e Sheet já consomem `getCampaignNav`; `isCampaignNavActive` já cobre paths aninhados (`/nova`, `[slug]`)
### Dados → forma
N/A — só navegação.
## Fases verificáveis
1. **Nav** — uma linha em `staffNav` + ícone
2. **Testes** — unit: href presente para staff, ausente para leader; `isCampaignNavActive` em lista/detalhe/nova
3. **Gates** — `pnpm gate:fast`; entrega `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Reordenar demais itens além do encaixe após Lideranças
- Exportar constante de href (só `MUNICIPALITY_NAV_HREF` tem consumidor especial B18)
- `staffSecondaryNav` — destino de trabalho, não referência
## Riscos e mitigação
- **Leader vê link:** mitigado — `getCampaignNav('leader')` retorna `leaderNav` separado
- **Active state em `/nova`:** mitigado — `isCampaignNavActive` usa `pathname.startsWith(\`${href}/\`)`
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit) para nav + active state

View file

@ -151,10 +151,6 @@ export const setMunicipalityEngagementLevelRecord = async (
) => {
const { municipality, level, note, triangulatedShock, override } =
municipalityEngagementLevelSchema.parse(input)
const levelNote = note ?? null
// allocationDecision.rationale is required; empty Motivo still leaves an
// auditable row without inventing coordinator prose.
const rationale = levelNote ?? 'Sem motivo registrado.'
return withPayloadTransaction(
payload,
@ -194,7 +190,7 @@ export const setMunicipalityEngagementLevelRecord = async (
const updated = await payload.update({
collection: 'municipality',
id: municipality,
data: { engagementLevel: level, levelNote },
data: { engagementLevel: level, levelNote: note },
depth: 0,
user: currentActor,
overrideAccess: false,
@ -207,7 +203,7 @@ export const setMunicipalityEngagementLevelRecord = async (
municipality,
patternId: ENGAGEMENT_LEVEL_PATTERN_ID,
outcome: 'movimento',
rationale,
rationale: note ?? '',
// Only the values the decision was taken on — no document dumps
// (the collection caps the serialized snapshot at 16 KB).
snapshot: {

View file

@ -32,6 +32,13 @@ const validateAllocationDecision: CollectionBeforeValidateHook = ({
throw new APIError('Informe a leitura alternativa ao descartar a sugestão.', 400)
}
if (
nextData.outcome !== 'movimento' &&
(typeof nextData.rationale !== 'string' || !nextData.rationale.trim())
) {
throw new APIError('Informe a justificativa.', 400)
}
try {
const serializedSnapshot = JSON.stringify(nextData.snapshot)
if (
@ -116,7 +123,8 @@ export const AllocationDecision: CollectionConfig = {
name: 'rationale',
type: 'textarea',
label: 'Justificativa',
required: true,
// E14 movimento may omit motivo (B134); triage outcomes validate in beforeValidate.
required: false,
maxLength: 2000,
},
{

View file

@ -45,8 +45,9 @@ type MunicipalityListLevelControlProps = {
/**
* E14 — unlike the trend and estimate cells next to it, this one submits
* explicitly: a movement may need an override / triangulated shock. Motivo is
* optional (B134/B147). Auto-saving would skip the confirmation rite.
* explicitly: a movement may need an override, and the motivo is optional.
* Auto-saving half of that would file an incomplete decision under the
* coordinator's name.
*/
export const MunicipalityListLevelControl = ({
municipalityID,
@ -170,7 +171,7 @@ export const MunicipalityListLevelControl = ({
{
municipalityId: municipalityID,
level: draftLevel,
note: note.trim() ? note : null,
note,
triangulatedShock,
override,
},

View file

@ -94,7 +94,7 @@ export const WizardTrendNoteStep = ({
<div className="flex flex-col gap-2">
<label htmlFor="wizard-trend-note" className="text-sm font-medium">
Por que mudar a tendência?
Por que mudar a tendência? (opcional)
</label>
<Textarea
id="wizard-trend-note"
@ -103,7 +103,6 @@ export const WizardTrendNoteStep = ({
onChange={(event) => setNote(event.target.value)}
rows={5}
maxLength={2000}
required
disabled={isPending}
autoFocus
className="min-h-28"

View file

@ -4,6 +4,7 @@ import {
HandshakeIcon,
HomeIcon,
InboxIcon,
LandmarkIcon,
LayoutDashboardIcon,
MapIcon,
MapPinIcon,
@ -37,6 +38,7 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Municípios', href: MUNICIPALITY_NAV_HREF, icon: MapPinIcon },
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Atividades', href: '/campanha/atividades', icon: CalendarDaysIcon },
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },

View file

@ -89,9 +89,8 @@ export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato move o nível de envolvimento.'
/**
* E14 + B134 — a movement still records hysteresis/override in the snapshot;
* Motivo is optional free text (empty OK). "O que faria voltar atrás" left the
* write model (B147 lands the policy soft-dep; historical snapshot scrub stays B134).
* E14 — a movement records the new level plus an optional motivo; hysteresis,
* triangulated shock and override stay on the write path (B134).
*/
export const municipalityEngagementLevelSchema = z.object({
municipality: positiveRelationshipId,

View file

@ -1,8 +1,12 @@
/**
* Degenerate omnibox for lists that only expose free-text search.
* Pure / client-safe. (Organizações graduated to a typed omnibox — chips + apply remain for search-only surfaces / B128 pins.)
* Degenerate omnibox for lists that only expose free-text search (B128:
* organizações). Pure / client-safe.
*/
import { type CampaignListOmniboxChip } from '@/lib/campaignListOmnibox'
import {
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
export type SearchOnlyListState = {
q?: string
@ -19,6 +23,9 @@ export const buildSearchOnlyOmniboxChips = (
state: SearchOnlyListState,
): CampaignListOmniboxChip[] => (state.q ? [{ id: 'q', label: chipLabel('Busca', state.q) }] : [])
export const buildSearchOnlyOmniboxSuggestions = (query: string): CampaignListOmniboxSuggestion[] =>
filterOmniboxSuggestionSeeds([], query)
export const applySearchOnlyOmniboxSuggestion = <T extends SearchOnlyListState>({
state,
suggestionId,
@ -34,3 +41,28 @@ export const applySearchOnlyOmniboxSuggestion = <T extends SearchOnlyListState>(
}
return { kind: 'url', state }
}
export const removeSearchOnlyOmniboxChip = <T extends SearchOnlyListState>({
state,
chipId,
withPageReset,
}: {
state: T
chipId: string
withPageReset: (next: T) => T
}): SearchOnlyOmniboxAction<T> => {
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
}
return { kind: 'url', state }
}
export const clearSearchOnlyOmnibox = <T extends SearchOnlyListState>({
cleared,
}: {
state: T
cleared: T
}): SearchOnlyOmniboxAction<T> => ({
kind: 'clear',
state: cleared,
})

View file

@ -0,0 +1,23 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
DECLARE
affected integer;
BEGIN
UPDATE allocation_decision
SET snapshot = snapshot - 'reversalSignals'
WHERE outcome = 'movimento'
AND snapshot ? 'reversalSignals';
GET DIAGNOSTICS affected = ROW_COUNT;
RAISE NOTICE 'strip_engagement_reversal_signals: removed reversalSignals from % row(s)', affected;
END $$;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Historical reversal text was deliberately dropped (B134); it cannot be restored.
await db.execute(sql`SELECT 1`)
}

View file

@ -38,6 +38,7 @@ import * as migration_20260730_010601_simplify_municipality_signal_fields from '
import * as migration_20260730_043306_simplify_leadership_fields from './20260730_043306_simplify_leadership_fields';
import * as migration_20260731_014319_add_campaign_notifications from './20260731_014319_add_campaign_notifications';
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
import * as migration_20260802_230000_strip_engagement_reversal_signals from './20260802_230000_strip_engagement_reversal_signals';
export const migrations = [
{
@ -240,4 +241,9 @@ export const migrations = [
down: migration_20260801_062558_add_campaign_vote_summary_snapshot.down,
name: '20260801_062558_add_campaign_vote_summary_snapshot'
},
{
up: migration_20260802_230000_strip_engagement_reversal_signals.up,
down: migration_20260802_230000_strip_engagement_reversal_signals.down,
name: '20260802_230000_strip_engagement_reversal_signals',
},
];

View file

@ -66,7 +66,7 @@ describe('municipality engagement level (E14)', () => {
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
})
it('accepts an empty motivo and still writes the movement', async () => {
it('records a movement with an empty motivo (B134)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
@ -85,7 +85,9 @@ describe('municipality engagement level (E14)', () => {
expect(decisions.docs).toHaveLength(1)
const decision = decisions.docs[0]!
fixtures.own('allocationDecision', decision.id)
expect(decision.rationale).toBe('Sem motivo registrado.')
expect(decision.rationale).toBe('')
expect(decision.snapshot).toMatchObject({ from: null, to: 'n1' })
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
})
it('holds a movement that breaks the rules and writes nothing', async () => {
@ -158,6 +160,7 @@ describe('municipality engagement level (E14)', () => {
// against undoing a decision, not against deepening it.
violations: ['dois-movimentos-no-mes'],
})
expect(latest.snapshot).not.toHaveProperty('reversalSignals')
})
it('accepts a two-level jump when a triangulated shock is declared', async () => {
@ -194,6 +197,7 @@ describe('municipality engagement level (E14)', () => {
triangulatedShock: true,
overridden: false,
})
expect(decisions.docs[0]!.snapshot).not.toHaveProperty('reversalSignals')
})
it('keeps the ladder with unrestricted staff — the advisor proposes, it does not move', async () => {

View file

@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/shell/nav'
import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths'
describe('organizations sidebar entry', () => {
it('is offered to staff after Lideranças', () => {
const hrefs = getCampaignNav('coordinator').map((item) => item.href)
const leadershipIndex = hrefs.indexOf('/campanha/liderancas')
const organizationsIndex = hrefs.indexOf(ORGANIZATIONS_LIST_PATH)
expect(organizationsIndex).toBeGreaterThan(-1)
expect(organizationsIndex).toBe(leadershipIndex + 1)
})
it('is visible to advisor and candidate', () => {
expect(getCampaignNav('advisor').map((item) => item.href)).toContain(ORGANIZATIONS_LIST_PATH)
expect(getCampaignNav('candidate').map((item) => item.href)).toContain(ORGANIZATIONS_LIST_PATH)
})
it('is hidden from leaders', () => {
expect(getCampaignNav('leader').map((item) => item.href)).not.toContain(ORGANIZATIONS_LIST_PATH)
})
it('marks list, detail, and nova as active', () => {
expect(isCampaignNavActive(ORGANIZATIONS_LIST_PATH, ORGANIZATIONS_LIST_PATH)).toBe(true)
expect(isCampaignNavActive('/campanha/organizacoes/sindmed', ORGANIZATIONS_LIST_PATH)).toBe(
true,
)
expect(isCampaignNavActive('/campanha/organizacoes/nova', ORGANIZATIONS_LIST_PATH)).toBe(true)
})
})

View file

@ -3,6 +3,9 @@ import { describe, expect, it } from 'vitest'
import {
applySearchOnlyOmniboxSuggestion,
buildSearchOnlyOmniboxChips,
buildSearchOnlyOmniboxSuggestions,
clearSearchOnlyOmnibox,
removeSearchOnlyOmniboxChip,
} from '@/lib/searchOnlyListOmnibox'
import {
applyActivityOmniboxSuggestion,
@ -264,6 +267,21 @@ describe('list omnibox adapters (B128)', () => {
withPageReset: (next) => ({ ...next, page: 1 }),
})
expect(applied).toEqual({ kind: 'url', state: { page: 1, q: 'ana' } })
expect(buildSearchOnlyOmniboxSuggestions('x')[0]?.id).toBe('q:x')
const removed = removeSearchOnlyOmniboxChip({
state: { q: 'sindicato', page: 2 },
chipId: 'q',
withPageReset: (next) => ({ ...next, page: 1 }),
})
expect(removed).toEqual({ kind: 'url', state: { page: 1, q: undefined } })
const cleared = clearSearchOnlyOmnibox({
state: { q: 'sindicato' },
cleared: {},
})
expect(cleared).toEqual({ kind: 'clear', state: {} })
})
it('advisor toggles municipality portfolio filter and suggests by name', () => {

View file

@ -0,0 +1,26 @@
// @vitest-environment node
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const migration = readFileSync(
new URL(
'../../src/migrations/20260802_230000_strip_engagement_reversal_signals.ts',
import.meta.url,
),
'utf8',
)
describe('strip engagement reversal signals migration (B134)', () => {
it('removes reversalSignals only from movimento snapshots', () => {
expect(migration).toContain("snapshot = snapshot - 'reversalSignals'")
expect(migration).toContain("outcome = 'movimento'")
expect(migration).toContain("snapshot ? 'reversalSignals'")
expect(migration).toContain('GET DIAGNOSTICS affected = ROW_COUNT')
})
it('does not attempt to restore dropped data on down', () => {
const down = migration.indexOf('export async function down')
expect(migration.indexOf('reversalSignals', down)).toBe(-1)
})
})