Merge pull request #329 from fsolla/cursor/pool-i288-97b0de1d-f857

B134 — Motivo opcional (tendência + nível); remover voltar atrás do DB
This commit is contained in:
Francisco Solla 2026-08-02 20:25:23 -03:00 committed by GitHub
commit 6539d0cc55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 166 additions and 44 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

@ -149,7 +149,7 @@ export const setMunicipalityEngagementLevelRecord = async (
actor: CampaignUser,
input: MunicipalityEngagementLevelInput,
) => {
const { municipality, level, note, reversalSignals, triangulatedShock, override } =
const { municipality, level, note, triangulatedShock, override } =
municipalityEngagementLevelSchema.parse(input)
return withPayloadTransaction(
@ -203,13 +203,12 @@ export const setMunicipalityEngagementLevelRecord = async (
municipality,
patternId: ENGAGEMENT_LEVEL_PATTERN_ID,
outcome: 'movimento',
rationale: note,
rationale: note ?? '',
// Only the values the decision was taken on — no document dumps
// (the collection caps the serialized snapshot at 16 KB).
snapshot: {
from,
to: level,
reversalSignals,
triangulatedShock,
violations: violations.map((violation) => violation.id),
overridden: violations.length > 0,

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,9 +45,9 @@ type MunicipalityListLevelControlProps = {
/**
* E14 — unlike the trend and estimate cells next to it, this one submits
* explicitly: a movement carries a motivo AND the signals that would reverse
* it, and it may need an override. Auto-saving half of that would file an
* incomplete decision under the coordinator's name.
* 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,
@ -73,7 +73,6 @@ export const MunicipalityListLevelControl = ({
// with it the select — is not mounted.
const [draftLevel, setDraftLevel] = useState<EngagementLevel | ''>('')
const [note, setNote] = useState('')
const [reversalSignals, setReversalSignals] = useState('')
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [isPending, setIsPending] = useState(false)
@ -142,7 +141,6 @@ export const MunicipalityListLevelControl = ({
const resetDraft = () => {
setDraftLevel(currentLevel ?? '')
setNote('')
setReversalSignals('')
setTriangulatedShock(false)
setOverride(false)
setErrorMessage(null)
@ -174,7 +172,6 @@ export const MunicipalityListLevelControl = ({
municipalityId: municipalityID,
level: draftLevel,
note,
reversalSignals,
triangulatedShock,
override,
},
@ -205,11 +202,7 @@ export const MunicipalityListLevelControl = ({
}
}
const canSubmit =
isMovement &&
note.trim().length > 0 &&
reversalSignals.trim().length > 0 &&
(violations.length === 0 || override)
const canSubmit = isMovement && (violations.length === 0 || override)
const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}`
const isSheet = variant === 'sheet'
const submitButton = (
@ -283,7 +276,7 @@ export const MunicipalityListLevelControl = ({
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
<FieldLabel htmlFor={fieldId('note')}>Motivo (opcional)</FieldLabel>
<Textarea
id={fieldId('note')}
value={note}
@ -294,18 +287,6 @@ export const MunicipalityListLevelControl = ({
placeholder="O que mudou para justificar este nível?"
/>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('reversal')}>O que faria voltar atrás</FieldLabel>
<Textarea
id={fieldId('reversal')}
value={reversalSignals}
onChange={(event) => setReversalSignals(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="Sinais que fariam rever esta decisão."
/>
</Field>
{isJump ? (
<Field orientation="horizontal" className={isSheet ? 'min-h-11' : undefined}>
<Checkbox

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

@ -89,15 +89,13 @@ export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato move o nível de envolvimento.'
/**
* E14 — a movement is never just the new level: the motivo and the signals
* that would reverse it are what make the decision auditable later, so both
* are required by the schema rather than by the form alone.
* 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,
level: z.enum(engagementLevels),
note: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
reversalSignals: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
note: trimmedNullableText(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
/** Licenses a two-level jump (research §6.8). */
triangulatedShock: z.boolean().default(false),
/** Accepts the listed violations knowingly; recorded in the decision snapshot. */

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

@ -41,7 +41,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n3',
note: 'Prefeitura aliada e duas lideranças novas.',
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
})
fixtures.touchMunicipality(municipality.id)
@ -61,10 +60,34 @@ describe('municipality engagement level (E14)', () => {
expect(decision.snapshot).toMatchObject({
from: null,
to: 'n3',
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
violations: [],
overridden: false,
})
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
})
it('records a movement with an empty motivo (B134)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const updated = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: null,
})
fixtures.touchMunicipality(municipality.id)
expect(updated.engagementLevel).toBe('n1')
expect(updated.levelNote).toBeNull()
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
const decision = decisions.docs[0]!
fixtures.own('allocationDecision', decision.id)
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 () => {
@ -76,7 +99,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa como presença de mandato.',
reversalSignals: 'Nenhuma agenda executada no trimestre.',
})
fixtures.touchMunicipality(municipality.id)
@ -86,7 +108,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n3',
note: 'Achismo da semana.',
reversalSignals: 'Nada.',
}).catch((error: unknown) => error)
expect(blocked).toBeInstanceOf(EngagementLevelBlockedError)
@ -115,7 +136,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa.',
reversalSignals: 'Agenda vazia por um trimestre.',
})
fixtures.touchMunicipality(municipality.id)
@ -123,7 +143,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n2',
note: 'Adversário abriu comitê e o prefeito pediu presença.',
reversalSignals: 'Comitê adversário fechar.',
override: true,
})
expect(moved.engagementLevel).toBe('n2')
@ -141,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 () => {
@ -164,7 +184,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n3',
note: 'Três sinais independentes de invasão na mesma semana.',
reversalSignals: 'Adversário recuar do território.',
triangulatedShock: true,
})
expect(moved.engagementLevel).toBe('n3')
@ -178,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 () => {
@ -191,7 +211,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n2',
note: 'Quero subir o meu município.',
reversalSignals: 'Nada.',
}),
).rejects.toThrow('coordenação geral ou o candidato')
@ -218,7 +237,6 @@ describe('municipality engagement level (E14)', () => {
municipality: municipality.id,
level: 'n4',
note: 'Prioridade máxima do ciclo.',
reversalSignals: 'Queda de captura por dois meses.',
})
fixtures.touchMunicipality(municipality.id)
const decisions = await findDecisions(municipality.id)

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