mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
commit
6539d0cc55
10 changed files with 166 additions and 44 deletions
64
docs/plans/motivo-opcional-tendencia-e-nivel-impl.md
Normal file
64
docs/plans/motivo-opcional-tendencia-e-nivel-impl.md
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue