teqo/src/lib/wizardActionChain.ts
Francisco Solla 212a1f2423 feat(campaign): C87 — atualização unificada (texto + polaridade + urgente)
Substitui o modelo de MunicipalityUpdate (kind/semanal/urgente/nota/sinal + signalType)
por um modelo unificado: body obrigatório + polarity (boa/neutra/ruim) + urgent + adversarySignal.

- Schema: polarity enum obrigatório, urgent boolean default false, adversarySignal boolean
- Wizard: novo WizardUpdateBodyStep com campo de texto + polaridade + urgente
- Lista: controle unificado de atualização com polaridade inline
- Feed: exibe polaridade como badge colorido + indicador de urgência
- V2 status strip: atualização rápida de polaridade
- E11: queries atualizadas para polarity + adversarySignal
- Migração: 20260807_030000_unify_municipality_update (backfill polarity=neutra, urgent de kind, adversarySignal de kind+signalType)
- Testes: 18/18 integração C87, 593/593 total, 1399/1399 unitários
- Fix: testes de state deputy usavam nomes fixos que colidiam entre execuções
2026-08-07 05:04:41 -03:00

240 lines
7 KiB
TypeScript

/**
* B98 — ordered sub-flow queue after the principal wizard action succeeds.
* Pure navigation helpers; each link still writes via its existing action.
*/
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignWizardActionIdForSlug,
isWizardReturnPath,
wizardActionHref,
wizardTrendHref,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
/** Actions that participate in the v1 adjustment chain (excludes demand). */
export type WizardChainActionId = Exclude<CampaignWizardActionId, 'register-demand'>
const WIZARD_CHAIN_AFTER = {
'update-votes': ['change-trend', 'register-update', 'update-leadership'],
'register-update': ['change-trend', 'update-votes', 'update-leadership'],
'change-trend': ['register-update', 'update-votes', 'update-leadership'],
'update-leadership': ['register-update', 'update-votes', 'change-trend'],
} as const satisfies Record<WizardChainActionId, readonly WizardChainActionId[]>
export const isWizardChainActionId = (
id: CampaignWizardActionId | undefined,
): id is WizardChainActionId => id != null && id in WIZARD_CHAIN_AFTER
/**
* Remaining chained steps after `completedAction` in a session owned by `entryAction`.
* When `completedAction` is the principal (or omitted), returns the full queue.
*/
export const wizardChainAfter = (
entryAction: WizardChainActionId,
completedAction?: WizardChainActionId,
): WizardChainActionId[] => {
const queue: WizardChainActionId[] = [...WIZARD_CHAIN_AFTER[entryAction]]
if (!completedAction || completedAction === entryAction) {
return queue
}
const index = queue.indexOf(completedAction)
if (index === -1) {
return []
}
return queue.slice(index + 1)
}
export const nextWizardChainStep = (
entryAction: WizardChainActionId,
completedAction: WizardChainActionId,
): WizardChainActionId | undefined => wizardChainAfter(entryAction, completedAction)[0]
const wizardHrefForChainStep = (
action: WizardChainActionId,
municipalitySlug: string,
entryAction: WizardChainActionId,
returnPath?: string,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
case 'register-update':
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
case 'change-trend':
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)
default: {
const exhaustive: never = action
return exhaustive
}
}
}
/** Chain end or dismiss target: allowlisted `from`, else Início (B110). */
export const wizardChainEndHref = (returnPath?: string): string =>
returnPath && isWizardReturnPath(returnPath) ? returnPath : CAMPAIGN_HOME
/**
* Href for the next chained wizard after completing/skipping `completedAction`,
* or the return path (default Início) when the queue is empty / entry is not chainable.
*/
export const wizardChainContinueHref = (
entryAction: CampaignWizardActionId,
completedAction: WizardChainActionId,
municipalitySlug: string,
returnPath?: string,
): string => {
if (!isWizardChainActionId(entryAction)) {
return wizardChainEndHref(returnPath)
}
const next = nextWizardChainStep(entryAction, completedAction)
if (!next) {
return wizardChainEndHref(returnPath)
}
return wizardHrefForChainStep(next, municipalitySlug, entryAction, returnPath)
}
/** Session owner for navigation: explicit `entry` query, else the current principal. */
export const resolveWizardChainEntry = (
entryAction: CampaignWizardActionId | undefined,
currentAction: WizardChainActionId,
): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction)
/**
* register-update, update-votes, update-leadership all use wizardActionHref.
*/
type WizardStepKind =
| 'municipality-search'
| 'votes'
| 'trend-choice'
| 'trend-note'
| 'update-body'
| 'leadership-grid'
| 'leadership-form'
const wizardPrincipalStepHref = (
action: WizardChainActionId,
municipalitySlug: string,
sessionEntry: WizardChainActionId,
returnPath?: string,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
case 'register-update':
return wizardActionHref(actionSlug, municipalitySlug, {
entryAction: sessionEntry,
returnPath,
})
case 'change-trend':
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
sessionEntry,
undefined,
returnPath,
)
default: {
const exhaustive: never = action
return exhaustive
}
}
}
/**
* Principal step of the chain link immediately before `current` in a session owned by
* `sessionEntry`. Undefined when `current` is the session principal (standalone entry).
*/
const wizardChainPreviousPrincipalHref = (
current: WizardChainActionId,
sessionEntry: WizardChainActionId,
municipalitySlug: string,
returnPath?: string,
): string | undefined => {
if (current === sessionEntry) {
return undefined
}
const queue = wizardChainAfter(sessionEntry)
const index = queue.indexOf(current)
if (index === -1) {
return undefined
}
if (index === 0) {
return wizardPrincipalStepHref(sessionEntry, municipalitySlug, sessionEntry, returnPath)
}
return wizardPrincipalStepHref(queue[index - 1], municipalitySlug, sessionEntry, returnPath)
}
type WizardPreviousHrefInput = {
actionSlug: string
stepKind: WizardStepKind
municipalitySlug?: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
/**
* Href for the logically previous wizard step — internal sub-steps, principal search,
* or the preceding B98 chain link's principal step (same município).
*/
export const wizardPreviousHref = (input: WizardPreviousHrefInput): string => {
const { actionSlug, stepKind, municipalitySlug, entryAction, returnPath } = input
if (stepKind === 'municipality-search') {
return wizardChainEndHref(returnPath)
}
if (stepKind === 'trend-note') {
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)
}
if (stepKind === 'leadership-form') {
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
}
const currentActionId = campaignWizardActionIdForSlug(actionSlug)
const searchHref = wizardActionHref(actionSlug, undefined, { returnPath })
if (!municipalitySlug || !currentActionId || !isWizardChainActionId(currentActionId)) {
return searchHref
}
const sessionEntry = resolveWizardChainEntry(entryAction, currentActionId)
if (
(stepKind === 'votes' || stepKind === 'leadership-grid') &&
currentActionId === sessionEntry
) {
return searchHref
}
const chainPrev = wizardChainPreviousPrincipalHref(
currentActionId,
sessionEntry,
municipalitySlug,
returnPath,
)
if (chainPrev) {
return chainPrev
}
return searchHref
}