Merge pull request #154 from fsolla/cursor/pool-i149-d8165a59-5947

B110 — Wizard return path via allowlisted from query
This commit is contained in:
Francisco Solla 2026-08-01 18:04:14 -03:00 committed by GitHub
commit 8741eb5151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 351 additions and 88 deletions

View file

@ -15,12 +15,14 @@ import {
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
parseWizardReturnPath,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
WIZARD_ENTRY_ACTION_QUERY_KEY,
WIZARD_LEADERSHIP_ID_QUERY_KEY,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_RETURN_PATH_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
WIZARD_SIGNAL_BODY_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
@ -31,14 +33,17 @@ import {
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
resolveWizardTrendNoteDestination,
} from '@/lib/politicalTrendWizardUi'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { isWizardChainActionId, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
isWizardChainActionId,
wizardChainContinueHref,
wizardChainEndHref,
} from '@/lib/wizardActionChain'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData'
@ -83,6 +88,7 @@ export default async function CampaignActionWizardPage({
}: CampaignActionWizardPageProps) {
const { slug } = await params
const resolvedSearchParams = await searchParams
const returnPath = parseWizardReturnPath(resolvedSearchParams[WIZARD_RETURN_PATH_QUERY_KEY])
if (!isCampaignWizardActionSlug(slug)) {
notFound()
@ -104,7 +110,8 @@ export default async function CampaignActionWizardPage({
return (
<WizardMunicipalitySearchStep
actionSlug={slug}
previousHref={CAMPAIGN_HOME}
previousHref={wizardChainEndHref(returnPath)}
returnPath={returnPath}
accessibleMunicipalities={accessibleMunicipalities}
/>
)
@ -142,6 +149,7 @@ export default async function CampaignActionWizardPage({
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
@ -164,6 +172,7 @@ export default async function CampaignActionWizardPage({
entryAction={entryAction}
initialTiles={tiles}
initialLeadershipId={initialLeadershipId}
returnPath={returnPath}
/>
)
}
@ -200,9 +209,11 @@ export default async function CampaignActionWizardPage({
if (noteDestination === 'home') {
// Chained session: advance the queue (B98). Standalone stale deep-link: Início (B97).
if (isWizardChainActionId(entryAction)) {
redirect(wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug))
redirect(
wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug, returnPath),
)
}
redirect(CAMPAIGN_HOME)
redirect(wizardChainEndHref(returnPath))
}
if (noteDestination === 'choice') {
@ -220,6 +231,7 @@ export default async function CampaignActionWizardPage({
trendStatus={trendStatus}
initialNote={initialNote}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
@ -232,6 +244,7 @@ export default async function CampaignActionWizardPage({
currentStatus={currentStatus}
entryAction={entryAction}
prefillExtraParams={prefillExtraParams}
returnPath={returnPath}
/>
)
}
@ -257,6 +270,7 @@ export default async function CampaignActionWizardPage({
municipalitySlug={municipality.slug}
signalType={signalType}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
@ -267,9 +281,16 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
return (
<WizardMunicipalitySelectedStub
actionSlug={slug}
municipalityName={municipality.name}
returnPath={returnPath}
/>
)
}

View file

@ -2,6 +2,7 @@
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
export const CampaignHomeActions = ({
@ -11,7 +12,10 @@ export const CampaignHomeActions = ({
role: CampaignRole
uncoveredMunicipalitiesHref?: string
}) => {
const actions = toHomeActionButtonProps(homeActionsForRole(role), uncoveredMunicipalitiesHref)
const actions = toHomeActionButtonProps(homeActionsForRole(role), {
uncoveredMunicipalitiesHref,
returnPath: CAMPAIGN_HOME,
})
if (actions.length === 0) return null

View file

@ -22,7 +22,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
WIZARD_LEADERSHIP_ADD_TILE_LABEL,
WIZARD_LEADERSHIP_CONTINUE_LABEL,
@ -36,7 +35,11 @@ import {
} from '@/lib/campaignWizardCopy'
import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate'
import { cn } from '@/lib/utils'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardLeadershipSkip,
type WizardLeadershipTileViewModel,
@ -50,6 +53,7 @@ type WizardLeadershipStepProps = {
entryAction?: CampaignWizardActionId
initialTiles: WizardLeadershipTileViewModel[]
initialLeadershipId?: number
returnPath?: string
}
type WizardLeadershipMode =
@ -70,6 +74,7 @@ export const WizardLeadershipStep = ({
entryAction,
initialTiles,
initialLeadershipId,
returnPath,
}: WizardLeadershipStepProps) => {
const router = useRouter()
const [isContinuing, startContinueTransition] = useTransition()
@ -84,11 +89,12 @@ export const WizardLeadershipStep = ({
const [dirty, setDirty] = useState(false)
const [infoTile, setInfoTile] = useState<WizardLeadershipTileViewModel | null>(null)
const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug)
const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug, returnPath)
const chainContinueHref = wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
returnPath,
)
const stepTitle =
@ -123,8 +129,8 @@ export const WizardLeadershipStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={stepTitle}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skipConfig}
trailingAction={trailingAction}

View file

@ -17,7 +17,11 @@ import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardSignalSkip,
WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX,
@ -31,6 +35,7 @@ type WizardSignalBodyStepProps = {
municipalitySlug: string
signalType: MunicipalitySignalType
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardSignalBodyStep = ({
@ -40,19 +45,22 @@ export const WizardSignalBodyStep = ({
municipalitySlug,
signalType,
entryAction,
returnPath,
}: WizardSignalBodyStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(
createMunicipalityListSignalFormAction,
{},
)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-signal')
router.replace(wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug))
router.replace(
wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug, returnPath),
)
})
return (
@ -60,7 +68,14 @@ export const WizardSignalBodyStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)}
previousHref={wizardSignalHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
returnPath,
)}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardSignalSkipTrailing skip={skip} /> : undefined}

View file

@ -25,6 +25,7 @@ import {
} from '@/lib/municipalitySignalTypeMeta'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
import { resolveWizardSignalSkip, WIZARD_SIGNAL_TYPE_STEP_TITLE } from '@/lib/wizardSignalUi'
type WizardSignalTypeStepProps = {
@ -32,6 +33,7 @@ type WizardSignalTypeStepProps = {
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardSignalTypeStep = ({
@ -39,10 +41,11 @@ export const WizardSignalTypeStep = ({
municipalityName,
municipalitySlug,
entryAction,
returnPath,
}: WizardSignalTypeStepProps) => {
const [infoType, setInfoType] = useState<MunicipalitySignalType | null>(null)
const infoEntry = infoType ? municipalitySignalTypeMetaByType[infoType] : null
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath)
return (
<>
@ -50,7 +53,8 @@ export const WizardSignalTypeStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE}
previousHref={wizardActionHref(actionSlug)}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
contentAlign="end"
skip={skip}
@ -62,7 +66,13 @@ export const WizardSignalTypeStep = ({
return (
<li key={entry.type} className="relative">
<CampaignWizardNavLink
href={wizardSignalHref(actionSlug, municipalitySlug, entry.type, entryAction)}
href={wizardSignalHref(
actionSlug,
municipalitySlug,
entry.type,
entryAction,
returnPath,
)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border border-border bg-transparent p-3 pr-10 text-left',
'transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',

View file

@ -30,6 +30,7 @@ import {
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { cn } from '@/lib/utils'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
type WizardTrendChoiceStepProps = {
actionSlug: string
@ -38,6 +39,7 @@ type WizardTrendChoiceStepProps = {
currentStatus: PoliticalTrendStatusValue | null
entryAction?: CampaignWizardActionId
prefillExtraParams?: Record<string, string>
returnPath?: string
}
export const WizardTrendChoiceStep = ({
@ -47,9 +49,10 @@ export const WizardTrendChoiceStep = ({
currentStatus,
entryAction,
prefillExtraParams,
returnPath,
}: WizardTrendChoiceStepProps) => {
const [infoEntry, setInfoEntry] = useState<PoliticalTrendWizardMetaEntry | null>(null)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath)
const options = selectablePoliticalTrendStatuses(currentStatus)
return (
@ -58,7 +61,8 @@ export const WizardTrendChoiceStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={wizardTrendChoiceStepTitle(currentStatus)}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction })}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}
@ -76,6 +80,7 @@ export const WizardTrendChoiceStep = ({
status,
entryAction,
prefillExtraParams,
returnPath,
)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border bg-background p-3 pr-10 text-left',

View file

@ -21,7 +21,11 @@ import {
WIZARD_TREND_SAVE_LABEL,
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
} from '@/lib/wizardActionChain'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
type WizardTrendNoteStepProps = {
@ -32,6 +36,7 @@ type WizardTrendNoteStepProps = {
trendStatus: PoliticalTrendStatusValue
initialNote: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardTrendNoteStep = ({
@ -42,6 +47,7 @@ export const WizardTrendNoteStep = ({
trendStatus,
initialNote,
entryAction,
returnPath,
}: WizardTrendNoteStepProps) => {
const router = useRouter()
const [note, setNote] = useState(initialNote)
@ -49,13 +55,15 @@ export const WizardTrendNoteStep = ({
setMunicipalityPoliticalTrendFormAction,
{},
)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = `Mudar tendência para ${politicalTrendLabels[trendStatus]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend')
router.replace(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug))
router.replace(
wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath),
)
})
return (
@ -63,7 +71,15 @@ export const WizardTrendNoteStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)}
previousHref={wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}

View file

@ -19,7 +19,6 @@ import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
resolveWizardVotesSkip,
WIZARD_VOTES_FINAL_CTA_LABEL,
@ -28,7 +27,11 @@ import {
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
} from '@/lib/wizardActionChain'
import {
applyVoteShortcut,
getWizardVoteViolation,
@ -45,6 +48,7 @@ type WizardExpectedVotesStepProps = {
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardExpectedVotesStep = ({
@ -54,6 +58,7 @@ export const WizardExpectedVotesStep = ({
municipalitySlug,
initialExpectedVotes,
entryAction,
returnPath,
}: WizardExpectedVotesStepProps) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
@ -61,7 +66,7 @@ export const WizardExpectedVotesStep = ({
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const [violation, setViolation] = useState<WizardVoteViolation | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const skip = resolveWizardVotesSkip(entryAction, municipalitySlug)
const skip = resolveWizardVotesSkip(entryAction, municipalitySlug, returnPath)
const clearViolationState = () => {
if (violation) setViolation(null)
@ -80,7 +85,9 @@ export const WizardExpectedVotesStep = ({
const continueAfterVotes = () => {
const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes')
router.replace(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug))
router.replace(
wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath),
)
}
const handleConfirm = () => {
@ -131,8 +138,8 @@ export const WizardExpectedVotesStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={trailingAction}

View file

@ -53,6 +53,7 @@ type WizardSearchResultsState =
type WizardMunicipalitySearchStepProps = {
actionSlug: string
previousHref: string
returnPath?: string
accessibleMunicipalities: AccessibleMunicipality[]
}
@ -72,6 +73,7 @@ const buildSuggestSuccessState = (
export const WizardMunicipalitySearchStep = ({
actionSlug,
previousHref,
returnPath,
accessibleMunicipalities,
}: WizardMunicipalitySearchStepProps) => {
const { query, setRaw, isDebouncing } = useHomeSearchQuery()
@ -216,7 +218,7 @@ export const WizardMunicipalitySearchStep = ({
{displayRows.map(({ hit, continuityReason }) => (
<li key={hit.slug}>
<HomeSearchHitRow
href={wizardActionHref(actionSlug, hit.slug)}
href={wizardActionHref(actionSlug, hit.slug, { returnPath })}
primary={hit.name}
secondary={continuityReason ?? hit.region}
showPriority={hit.priority === 'alta'}

View file

@ -1,27 +1,29 @@
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
wizardFlowTitleForSlug,
wizardNextStepPlaceholder,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
type WizardMunicipalitySelectedStubProps = {
actionSlug: string
municipalityName: string
returnPath?: string
}
export const WizardMunicipalitySelectedStub = ({
actionSlug,
municipalityName,
returnPath,
}: WizardMunicipalitySelectedStubProps) => (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>

View file

@ -43,6 +43,7 @@ const listQuickActions = (): readonly CampaignQuickAction[] => [
const wizardActionsWithMunicipalityPrefill = (
role: CampaignRole,
municipalitySlug: string,
returnPath: string,
): readonly CampaignQuickAction[] => {
const homeActions = homeActionsForRole(role).filter((action) =>
WIZARD_ACTION_IDS.includes(action.id as CampaignWizardActionId),
@ -55,7 +56,9 @@ const wizardActionsWithMunicipalityPrefill = (
label: action.label,
icon: action.icon,
description: action.description,
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug),
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug, {
returnPath,
}),
}
})
}
@ -93,6 +96,7 @@ export const resolveActivityQuickActions = (
surface: ActivityQuickActionSurface,
role: CampaignRole,
context: CampaignQuickActionContext,
pathname?: string,
): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) return []
@ -102,6 +106,7 @@ export const resolveActivityQuickActions = (
const activitySlug = context.activitySlug ?? surface.activitySlug
const municipalitySlug = context.municipalitySlug
const returnPath = pathname ?? `/campanha/atividades/${activitySlug}`
const detailActions = detailVerticalQuickActions(activitySlug)
@ -109,5 +114,8 @@ export const resolveActivityQuickActions = (
return detailActions
}
return [...wizardActionsWithMunicipalityPrefill(role, municipalitySlug), ...detailActions]
return [
...wizardActionsWithMunicipalityPrefill(role, municipalitySlug, returnPath),
...detailActions,
]
}

View file

@ -67,9 +67,48 @@ export const WIZARD_VOTE_TO_QUERY_KEY = 'voteTo' as const
export const WIZARD_LEADERSHIP_ID_QUERY_KEY = 'leadershipId' as const
export const WIZARD_RETURN_PATH_QUERY_KEY = 'from' as const
const WIZARD_RETURN_PATH_AUTH_PREFIXES = [
'/campanha/login',
'/campanha/convite',
'/campanha/redefinir-senha',
'/campanha/webauthn',
] as const
/** Allowlisted internal return paths for wizard dismiss / chain end (B110). */
export const isWizardReturnPath = (pathname: string): boolean => {
if (!pathname.startsWith('/campanha')) return false
if (pathname.startsWith('/campanha/acoes')) return false
for (const prefix of WIZARD_RETURN_PATH_AUTH_PREFIXES) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return false
}
return true
}
export const parseWizardReturnPath = (value: string | string[] | undefined): string | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) return undefined
if (trimmed.includes('://') || trimmed.startsWith('//')) return undefined
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
if (path.includes('?') || path.includes('#')) return undefined
return isWizardReturnPath(path) ? path : undefined
}
export const appendWizardReturnPath = (href: string, returnPath?: string): string => {
if (!returnPath || !isWizardReturnPath(returnPath)) return href
const queryIndex = href.indexOf('?')
const base = queryIndex === -1 ? href : href.slice(0, queryIndex)
const params = new URLSearchParams(queryIndex === -1 ? '' : href.slice(queryIndex + 1))
params.set(WIZARD_RETURN_PATH_QUERY_KEY, returnPath)
return `${base}?${params.toString()}`
}
export type WizardActionHrefOptions = {
entryAction?: CampaignWizardActionId
leadershipId?: number
returnPath?: string
}
export const wizardActionHref = (
@ -79,7 +118,7 @@ export const wizardActionHref = (
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug && !options?.leadershipId && !options?.entryAction) {
return base
return appendWizardReturnPath(base, options?.returnPath)
}
const params = new URLSearchParams()
if (municipalitySlug) {
@ -91,7 +130,8 @@ export const wizardActionHref = (
if (options?.leadershipId !== undefined) {
params.set(WIZARD_LEADERSHIP_ID_QUERY_KEY, String(options.leadershipId))
}
return `${base}?${params.toString()}`
const href = params.size > 0 ? `${base}?${params.toString()}` : base
return appendWizardReturnPath(href, options?.returnPath)
}
export const parseWizardMunicipioParam = (
@ -132,10 +172,11 @@ export const wizardSignalHref = (
municipalitySlug?: string,
signalType?: MunicipalitySignalType,
entryAction?: CampaignWizardActionId,
returnPath?: string,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
return appendWizardReturnPath(base, returnPath)
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
@ -145,7 +186,7 @@ export const wizardSignalHref = (
if (entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
}
return `${base}?${params.toString()}`
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
export const resolveWizardSignalTypeParam = (
@ -171,10 +212,11 @@ export const wizardTrendHref = (
trendStatus?: PoliticalTrendStatusValue,
entryAction?: CampaignWizardActionId,
extraParams?: Record<string, string>,
returnPath?: string,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
return appendWizardReturnPath(base, returnPath)
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
@ -189,7 +231,7 @@ export const wizardTrendHref = (
if (value) params.set(key, value)
}
}
return `${base}?${params.toString()}`
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
export const resolveWizardTrendStatusParam = (

View file

@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import {
appendWizardReturnPath,
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignActionEntryHref,
isCampaignWizardActionId,
@ -132,6 +133,7 @@ export const UNCOVERED_MUNICIPALITIES_LIST_HREF =
export type HomeActionButtonPropsOptions = {
uncoveredMunicipalitiesHref?: string
municipalitySlug?: string
returnPath?: string
}
/** Staff Início catalog with hrefs — shared by quick-action registries (B80+). */
@ -139,14 +141,20 @@ const staffHomeQuickActionsByRole = new Map<CampaignRole, readonly ResolvedCampa
export const resolveStaffHomeQuickActions = (
role: CampaignRole,
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => {
if (!isStaffCampaignRole(role)) return []
const cached = staffHomeQuickActionsByRole.get(role)
if (cached) return cached
if (!returnPath) {
const cached = staffHomeQuickActionsByRole.get(role)
if (cached) return cached
}
const actions = toHomeActionButtonProps(homeActionsForRole(role), {
uncoveredMunicipalitiesHref: UNCOVERED_MUNICIPALITIES_LIST_HREF,
returnPath,
})
staffHomeQuickActionsByRole.set(role, actions)
if (!returnPath) {
staffHomeQuickActionsByRole.set(role, actions)
}
return actions
}
@ -164,9 +172,17 @@ export const toHomeActionButtonProps = (
} else if (action.id === 'uncovered-municipalities') {
href = resolved.uncoveredMunicipalitiesHref
} else if (isCampaignWizardActionId(action.id)) {
href = resolved.municipalitySlug
? wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[action.id], resolved.municipalitySlug)
: campaignActionEntryHref(action.id)
if (resolved.municipalitySlug) {
href = wizardActionHref(
CAMPAIGN_WIZARD_ACTION_SLUGS[action.id],
resolved.municipalitySlug,
{
returnPath: resolved.returnPath,
},
)
} else {
href = appendWizardReturnPath(campaignActionEntryHref(action.id), resolved.returnPath)
}
}
return {
...action,

View file

@ -22,18 +22,20 @@ export const parseMunicipalityDetailSlug = (pathname: string): string | undefine
export const resolveMunicipalityListQuickActions = (
role: CampaignRole,
): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role)
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role, returnPath)
export const resolveMunicipalityDetailQuickActions = (
role: CampaignRole,
municipalitySlug: string,
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => {
if (!isStaffCampaignRole(role)) return []
const actions = homeActionsForRole(role).filter(
(action) => action.id !== 'uncovered-municipalities',
)
return toHomeActionButtonProps(actions, { municipalitySlug })
return toHomeActionButtonProps(actions, { municipalitySlug, returnPath })
}
export const resolveMunicipalityQuickActionsForPath = (
@ -42,12 +44,12 @@ export const resolveMunicipalityQuickActionsForPath = (
context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] => {
if (isMunicipalitiesListPath(pathname)) {
return resolveMunicipalityListQuickActions(role)
return resolveMunicipalityListQuickActions(role, pathname)
}
const slug = context.municipalitySlug ?? parseMunicipalityDetailSlug(pathname)
if (slug) {
return resolveMunicipalityDetailQuickActions(role, slug)
return resolveMunicipalityDetailQuickActions(role, slug, pathname)
}
return []

View file

@ -66,6 +66,7 @@ export const resolveDemandsListQuickActions = (
export const resolveDemandDetailQuickActions = (
role: CampaignRole,
context: CampaignQuickActionContext,
returnPath: string,
): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) {
return []
@ -93,7 +94,9 @@ export const resolveDemandDetailQuickActions = (
return [
{
...action,
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[id], municipalitySlug),
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[id], municipalitySlug, {
returnPath,
}),
},
]
})

View file

@ -42,7 +42,7 @@ export const resolveDobradinhasQuickActions = (
return []
}
const staffActions = resolveStaffHomeQuickActions(role)
const staffActions = resolveStaffHomeQuickActions(role, pathname)
if (isStateDeputyListPath(pathname)) {
return [newStateDeputyAction(), ...staffActions]

View file

@ -1,5 +1,6 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
appendWizardReturnPath,
campaignActionEntryHref,
wizardActionHref,
type CampaignWizardActionId,
@ -34,40 +35,46 @@ const parseLeadershipDetailId = (pathname: string): number | undefined => {
const resolveLeadershipListActions = (
role: CampaignRole,
returnPath: string,
): readonly ResolvedCampaignHomeAction[] => {
const actions = homeActionsForRole(role).filter((action) => action.id === 'update-leadership')
return actions.map((action) => ({
...action,
href: campaignActionEntryHref('update-leadership'),
href: appendWizardReturnPath(campaignActionEntryHref('update-leadership'), returnPath),
}))
}
const hrefForLeadershipDetailAction = (
action: CampaignHomeAction,
context: CampaignQuickActionContext,
returnPath: string,
): string => {
const wizardSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action.id as CampaignWizardActionId]
const { municipalitySlug, leadershipId } = context
if (action.id === 'update-leadership') {
if (municipalitySlug && leadershipId !== undefined) {
return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId })
return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId, returnPath })
}
if (leadershipId !== undefined) {
return `${LEADERSHIP_LIST_PATH}/${leadershipId}`
}
return campaignActionEntryHref('update-leadership')
return appendWizardReturnPath(campaignActionEntryHref('update-leadership'), returnPath)
}
if (municipalitySlug) {
return wizardActionHref(wizardSlug, municipalitySlug)
return wizardActionHref(wizardSlug, municipalitySlug, { returnPath })
}
return campaignActionEntryHref(action.id as CampaignWizardActionId)
return appendWizardReturnPath(
campaignActionEntryHref(action.id as CampaignWizardActionId),
returnPath,
)
}
const resolveLeadershipDetailActions = (
role: CampaignRole,
context: CampaignQuickActionContext,
returnPath: string,
): readonly ResolvedCampaignHomeAction[] => {
const actions = homeActionsForRole(role).filter((action) =>
LEADERSHIP_DETAIL_WIZARD_IDS.includes(action.id as CampaignWizardActionId),
@ -75,7 +82,7 @@ const resolveLeadershipDetailActions = (
return actions.map((action) => ({
...action,
href: hrefForLeadershipDetailAction(action, context),
href: hrefForLeadershipDetailAction(action, context, returnPath),
}))
}
@ -87,15 +94,19 @@ export const resolveLeadershipQuickActions = (
if (!isStaffCampaignRole(role)) return null
if (isLeadershipListPath(pathname)) {
return resolveLeadershipListActions(role)
return resolveLeadershipListActions(role, pathname)
}
const leadershipId = parseLeadershipDetailId(pathname)
if (leadershipId !== undefined) {
return resolveLeadershipDetailActions(role, {
...context,
leadershipId: context.leadershipId ?? leadershipId,
})
return resolveLeadershipDetailActions(
role,
{
...context,
leadershipId: context.leadershipId ?? leadershipId,
},
pathname,
)
}
return null

View file

@ -48,7 +48,7 @@ export const resolveQuickActionsForPath = (
const activitySurface = parseActivityQuickActionSurface(pathname)
if (activitySurface) {
return resolveActivityQuickActions(activitySurface, role, context)
return resolveActivityQuickActions(activitySurface, role, context, pathname)
}
const organizationSurface = parseOrganizationQuickActionSurface(pathname)
@ -60,7 +60,7 @@ export const resolveQuickActionsForPath = (
return resolveDemandsListQuickActions(role)
}
if (isDemandDetailPath(pathname)) {
return resolveDemandDetailQuickActions(role, context)
return resolveDemandDetailQuickActions(role, context, pathname)
}
const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context)
@ -79,7 +79,7 @@ export const resolveQuickActionsForPath = (
if (advisorActions.length > 0) return advisorActions
if (isTerritoriesListPath(pathname)) {
return resolveStaffHomeQuickActions(role)
return resolveStaffHomeQuickActions(role, pathname)
}
const referenceActions = resolveReferenceQuickActionsForPath(pathname, role, context)

View file

@ -49,6 +49,7 @@ const shouldShowWizardVotesSkip = (entryAction: CampaignWizardActionId | undefin
export const resolveWizardVotesSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardVotesSkipAction | undefined =>
shouldShowWizardVotesSkip(entryAction)
? {
@ -57,6 +58,7 @@ export const resolveWizardVotesSkip = (
resolveWizardChainEntry(entryAction, 'update-votes'),
'update-votes',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -69,6 +69,7 @@ export const shouldShowWizardTrendSkip = (
export const resolveWizardTrendSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardTrendSkipAction | undefined =>
shouldShowWizardTrendSkip(entryAction)
? {
@ -77,6 +78,7 @@ export const resolveWizardTrendSkip = (
resolveWizardChainEntry(entryAction, 'change-trend'),
'change-trend',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -6,6 +6,7 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
type CampaignWizardActionId,
isWizardReturnPath,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
@ -54,16 +55,24 @@ 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':
return wizardActionHref(actionSlug, municipalitySlug, { entryAction })
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
case 'register-signal':
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction, returnPath)
case 'change-trend':
return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)
default: {
const exhaustive: never = action
return exhaustive
@ -71,23 +80,28 @@ const wizardHrefForChainStep = (
}
}
/** 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 Início when the queue is empty / entry is not chainable.
* 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 CAMPAIGN_HOME
return wizardChainEndHref(returnPath)
}
const next = nextWizardChainStep(entryAction, completedAction)
if (!next) {
return CAMPAIGN_HOME
return wizardChainEndHref(returnPath)
}
return wizardHrefForChainStep(next, municipalitySlug, entryAction)
return wizardHrefForChainStep(next, municipalitySlug, entryAction, returnPath)
}
/** Session owner for navigation: explicit `entry` query, else the current principal. */

View file

@ -27,6 +27,7 @@ export const showLeadershipWizardSkip = (entryAction?: CampaignWizardActionId):
export const resolveWizardLeadershipSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardLeadershipSkipAction | undefined =>
showLeadershipWizardSkip(entryAction)
? {
@ -35,6 +36,7 @@ export const resolveWizardLeadershipSkip = (
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -22,6 +22,7 @@ export const shouldShowWizardSignalSkip = (
export const resolveWizardSignalSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardSignalSkipAction | undefined =>
shouldShowWizardSignalSkip(entryAction)
? {
@ -30,6 +31,7 @@ export const resolveWizardSignalSkip = (
resolveWizardChainEntry(entryAction, 'register-signal'),
'register-signal',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -218,7 +218,7 @@ describe('campaignQuickActionDobradinhas (B83)', () => {
expect(actions.map((action) => action.id)).toEqual(['new-state-deputy', ...staffActionIds])
expect(actions[0]?.href).toBe('/campanha/dobradinhas/nova')
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos',
'/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fdobradinhas',
)
expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe(
UNCOVERED_MUNICIPALITIES_LIST_HREF,
@ -279,7 +279,7 @@ describe('campaignQuickActionRegistry', () => {
})
expect(detail).toHaveLength(5)
expect(detail.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?municipio=foo',
'/campanha/acoes/atualizar-votos?municipio=foo&from=%2Fcampanha%2Fmunicipios%2Ffoo',
)
})
@ -303,7 +303,7 @@ describe('campaignQuickActionRegistry', () => {
)
const registerSignal = actions.find((action) => action.id === 'register-signal')
expect(registerSignal?.href).toBe(
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01`,
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01&from=%2Fcampanha%2Fatividades%2Fevento-zona-1`,
)
})
@ -311,7 +311,7 @@ describe('campaignQuickActionRegistry', () => {
const actions = resolveQuickActionsForPath('/campanha/territorios', 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(staffActionIds)
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos',
'/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fterritorios',
)
expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe(
UNCOVERED_MUNICIPALITIES_LIST_HREF,
@ -346,7 +346,7 @@ describe('campaignQuickActionRegistry', () => {
'register-demand',
])
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?municipio=cairu',
'/campanha/acoes/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fdemandas%2Fpedido-cairu',
)
expect(actions.find((action) => action.id === 'register-demand')?.href).toBe(
demandCreateHref(42),
@ -389,7 +389,11 @@ describe('campaignQuickActionDemands resolvers', () => {
it('returns empty catalog for leader lockdown', () => {
expect(resolveDemandsListQuickActions('leader')).toEqual([])
expect(
resolveDemandDetailQuickActions('leader', { municipalitySlug: 'cairu', municipalityId: 1 }),
resolveDemandDetailQuickActions(
'leader',
{ municipalitySlug: 'cairu', municipalityId: 1 },
'/campanha/demandas/foo',
),
).toEqual([])
})
})

View file

@ -8,7 +8,9 @@ describe('resolveLeadershipQuickActions', () => {
const actions = resolveLeadershipQuickActions('/campanha/liderancas', 'coordinator', {})
expect(actions).toHaveLength(1)
expect(actions?.[0]?.id).toBe('update-leadership')
expect(actions?.[0]?.href).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca`)
expect(actions?.[0]?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?from=%2Fcampanha%2Fliderancas`,
)
})
it('returns five wizard actions on detail with municipality prefill when N=1', () => {
@ -24,10 +26,10 @@ describe('resolveLeadershipQuickActions', () => {
'register-demand',
])
expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fliderancas%2F42`,
)
expect(actions?.find((action) => action.id === 'update-leadership')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42&from=%2Fcampanha%2Fliderancas%2F42`,
)
})
@ -39,7 +41,7 @@ describe('resolveLeadershipQuickActions', () => {
'/campanha/liderancas/7',
)
expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?from=%2Fcampanha%2Fliderancas%2F7`,
)
})

View file

@ -72,6 +72,16 @@ describe('wizardActionChain', () => {
expect(wizardChainContinueHref('register-demand', 'update-votes', 'cairu')).toBe(CAMPAIGN_HOME)
})
it('honors allowlisted return path when the chain ends', () => {
const origin = '/campanha/municipios/cairu'
expect(wizardChainContinueHref('change-trend', 'update-leadership', 'cairu', origin)).toBe(
origin,
)
expect(wizardChainContinueHref('update-votes', 'change-trend', 'cairu', origin)).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes&from=${encodeURIComponent(origin)}`,
)
})
it('resolves session entry from query or current principal', () => {
expect(resolveWizardChainEntry(undefined, 'update-votes')).toBe('update-votes')
expect(resolveWizardChainEntry('register-signal', 'update-votes')).toBe('register-signal')

View file

@ -27,8 +27,12 @@ describe('resolveWizardLeadershipSkip', () => {
expect(resolveWizardLeadershipSkip('update-leadership', 'cairu')).toBeUndefined()
})
it('points skip to Início when leadership is the last chained step', () => {
// In the v1 matrix, leadership is always the final chained elo.
it('points skip to origin when from is set, else Início at chain end', () => {
const origin = '/campanha/liderancas/7'
expect(resolveWizardLeadershipSkip('update-votes', 'cairu', origin)).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: origin,
})
expect(resolveWizardLeadershipSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: CAMPAIGN_HOME,

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
appendWizardReturnPath,
isWizardReturnPath,
parseWizardReturnPath,
WIZARD_RETURN_PATH_QUERY_KEY,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
describe('wizard return path (B110)', () => {
it('allowlists internal /campanha paths outside acoes and auth', () => {
expect(isWizardReturnPath('/campanha')).toBe(true)
expect(isWizardReturnPath('/campanha/municipios/cairu')).toBe(true)
expect(isWizardReturnPath('/campanha/liderancas/42')).toBe(true)
expect(isWizardReturnPath('/campanha/acoes/mudar-tendencia')).toBe(false)
expect(isWizardReturnPath('/campanha/login')).toBe(false)
expect(isWizardReturnPath('/campanha/convite/token')).toBe(false)
expect(isWizardReturnPath('https://evil.test/phish')).toBe(false)
expect(isWizardReturnPath('/public')).toBe(false)
})
it('parseWizardReturnPath fails closed on invalid values', () => {
expect(parseWizardReturnPath(undefined)).toBeUndefined()
expect(parseWizardReturnPath('/campanha/municipios/foo')).toBe('/campanha/municipios/foo')
expect(parseWizardReturnPath('/campanha/acoes/foo')).toBeUndefined()
expect(parseWizardReturnPath('//evil.test')).toBeUndefined()
expect(parseWizardReturnPath('/campanha/foo?x=1')).toBeUndefined()
})
it('appendWizardReturnPath adds the from query key', () => {
const href = appendWizardReturnPath(
'/campanha/acoes/mudar-tendencia?municipio=cairu',
'/campanha/municipios/cairu',
)
expect(href).toContain(`${WIZARD_RETURN_PATH_QUERY_KEY}=`)
expect(href).toContain(
`${WIZARD_RETURN_PATH_QUERY_KEY}=${encodeURIComponent('/campanha/municipios/cairu')}`,
)
})
it('appendWizardReturnPath ignores invalid return paths', () => {
expect(appendWizardReturnPath('/campanha/acoes/foo', '/campanha/acoes/bar')).toBe(
'/campanha/acoes/foo',
)
})
it('CAMPAIGN_HOME is a valid return path', () => {
expect(parseWizardReturnPath(CAMPAIGN_HOME)).toBe(CAMPAIGN_HOME)
})
})