From 789656bc70ebfc8802e1b2e9c034b8ba3233ef78 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 18:32:57 +0200 Subject: [PATCH] feat(interests): manual stage override + Residential Partner system role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual stage override Sales reps need to skip canTransitionStage rules when the data was entered out of order — e.g. recording a contract_signed deal whose earlier stages were never tracked in the system. - New permission flag interests.override_stage in RolePermissions. Plumbed through the schema TS type, the role-editor UI, the seed file's pre-built roles (super_admin/director/sales_manager get it, sales_agent + viewer don't), and the test factories. - changeStageSchema gains an optional `override` boolean and the service checks it before evaluating canTransitionStage. When override=true the reason field becomes required (min 5 chars) and is recorded in the audit log. - The route handler gates `override` on the new permission so a sales_agent without it can't pass override=true and bypass. - InterestStagePicker auto-detects when the requested transition is blocked by the table and switches into "override mode" — shows an amber warning, requires the reason, button label flips to "Override stage". When the operator lacks the permission, the warning is red and the button is disabled. Residential Partner role Per the smart-archive scoping conversation: external partners who handle residential inquiries shouldn't see marina clients, yachts, berths, or financials. The two residential_* permission groups already exist; this commit just seeds a pre-built system role ("residential_partner") with those flags + minimal own-reminders, so admins can invite a partner today via /admin/users without manually building the permission set. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/interests/[id]/stage/route.ts | 12 ++- src/components/admin/roles/role-form.tsx | 1 + .../interests/interest-stage-picker.tsx | 82 ++++++++++++++-- src/lib/db/schema/users.ts | 5 + src/lib/db/seed.ts | 93 +++++++++++++++++++ src/lib/services/interests.service.ts | 12 ++- src/lib/validators/interests.ts | 4 + tests/helpers/factories.ts | 4 + 8 files changed, 203 insertions(+), 10 deletions(-) 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 && ( + + )} +
- +