diff --git a/src/app/api/v1/interests/[id]/stage/route.ts b/src/app/api/v1/interests/[id]/stage/route.ts index 50a5bd3..bd30215 100644 --- a/src/app/api/v1/interests/[id]/stage/route.ts +++ b/src/app/api/v1/interests/[id]/stage/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ForbiddenError } from '@/lib/errors'; import { changeInterestStage } from '@/lib/services/interests.service'; import { changeStageSchema } from '@/lib/validators/interests'; @@ -10,6 +10,16 @@ export const PATCH = withAuth( withPermission('interests', 'change_stage', async (req, ctx, params) => { try { const body = await parseBody(req, changeStageSchema); + // Override (skip the canTransitionStage table) requires a stricter + // permission. Reason field validation lives in the service. + if (body.override) { + const allowed = ctx.isSuperAdmin || !!ctx.permissions?.interests?.override_stage; + if (!allowed) { + throw new ForbiddenError( + 'You do not have permission to override the stage transition rules.', + ); + } + } const interest = await changeInterestStage(params.id!, ctx.portId, body, { userId: ctx.userId, portId: ctx.portId, diff --git a/src/components/admin/roles/role-form.tsx b/src/components/admin/roles/role-form.tsx index fd89a97..a70dc02 100644 --- a/src/components/admin/roles/role-form.tsx +++ b/src/components/admin/roles/role-form.tsx @@ -27,6 +27,7 @@ const DEFAULT_PERMISSIONS: Record> = { edit: false, delete: false, change_stage: false, + override_stage: false, generate_eoi: false, export: false, }, diff --git a/src/components/interests/interest-stage-picker.tsx b/src/components/interests/interest-stage-picker.tsx index 7a92924..4c71b4e 100644 --- a/src/components/interests/interest-stage-picker.tsx +++ b/src/components/interests/interest-stage-picker.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Loader2 } from 'lucide-react'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -22,7 +23,8 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; -import { PIPELINE_STAGES, STAGE_LABELS, stageLabel } from '@/lib/constants'; +import { usePermissions } from '@/hooks/use-permissions'; +import { PIPELINE_STAGES, STAGE_LABELS, stageLabel, canTransitionStage } from '@/lib/constants'; interface InterestStagePickerProps { open: boolean; @@ -38,20 +40,41 @@ export function InterestStagePicker({ currentStage, }: InterestStagePickerProps) { const queryClient = useQueryClient(); + const { can, isSuperAdmin } = usePermissions(); const [newStage, setNewStage] = useState(currentStage); const [reason, setReason] = useState(''); + const [override, setOverride] = useState(false); + + // The transition table allows reasonable forward jumps; rejecting a + // proposed stage flips the UI into "override" mode if the user has + // permission to skip the rules. + const transitionAllowed = newStage === currentStage || canTransitionStage(currentStage, newStage); + const canOverride = isSuperAdmin || can('interests', 'override_stage'); + const overrideRequired = !transitionAllowed; + const overrideEffective = override || overrideRequired; + const reasonRequiredByOverride = overrideEffective; + const reasonValid = !reasonRequiredByOverride || reason.trim().length >= 5; const mutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/interests/${interestId}/stage`, { method: 'PATCH', - body: { pipelineStage: newStage, reason: reason || undefined }, + body: { + pipelineStage: newStage, + reason: reason || undefined, + override: overrideEffective || undefined, + }, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests', interestId] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); onOpenChange(false); setReason(''); + setOverride(false); + toast.success(overrideEffective ? 'Stage overridden' : 'Stage updated'); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Stage change failed'); }, }); @@ -84,12 +107,52 @@ export function InterestStagePicker({ + {overrideRequired && ( +
+ + {canOverride ? ( + + This is not a normal forward transition. Override is enabled — supply a reason + below explaining the manual stage change. Recorded in the audit log. + + ) : ( + + This stage transition isn’t allowed by the pipeline rules. You don’t + have permission to override. + + )} +
+ )} + + {transitionAllowed && canOverride && newStage !== currentStage && ( + + )} +
- +