feat(interests): manual stage override + Residential Partner system role
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
|
||||
@@ -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<string>(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({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{overrideRequired && (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border p-3 text-xs ${
|
||||
canOverride
|
||||
? 'border-amber-300 bg-amber-50 text-amber-900'
|
||||
: 'border-red-300 bg-red-50 text-red-900'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
{canOverride ? (
|
||||
<span>
|
||||
This is not a normal forward transition. Override is enabled — supply a reason
|
||||
below explaining the manual stage change. Recorded in the audit log.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
This stage transition isn’t allowed by the pipeline rules. You don’t
|
||||
have permission to override.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transitionAllowed && canOverride && newStage !== currentStage && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={override}
|
||||
onChange={(e) => setOverride(e.target.checked)}
|
||||
/>
|
||||
Force-override (skip transition rules) — requires a reason
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Reason (optional)</Label>
|
||||
<Label>
|
||||
Reason {reasonRequiredByOverride && <span className="text-red-600">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Reason for stage change..."
|
||||
placeholder={
|
||||
reasonRequiredByOverride
|
||||
? 'Required: why is this stage being overridden? (min 5 chars)'
|
||||
: 'Optional: reason for stage change...'
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
@@ -105,10 +168,15 @@ export function InterestStagePicker({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || newStage === currentStage}
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
newStage === currentStage ||
|
||||
(overrideRequired && !canOverride) ||
|
||||
!reasonValid
|
||||
}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm
|
||||
{overrideEffective ? 'Override stage' : 'Confirm'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,11 @@ export type RolePermissions = {
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
change_stage: boolean;
|
||||
/** Bypass the canTransitionStage table (e.g. mark a contract_signed
|
||||
* deal as completed without going through deposit_10pct first when
|
||||
* the data was entered out of order). Audit-logged with the reason
|
||||
* the rep gives. Sales-team-restricted. */
|
||||
override_stage: boolean;
|
||||
generate_eoi: boolean;
|
||||
export: boolean;
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ const ALL_PERMISSIONS: RolePermissions = {
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
@@ -110,6 +111,7 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
@@ -185,6 +187,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
@@ -260,6 +263,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
@@ -335,6 +339,7 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
@@ -402,6 +407,85 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner — for an outside party who handles residential
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
send_for_signing: false,
|
||||
upload_signed: false,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
export: false,
|
||||
scan_receipt: false,
|
||||
},
|
||||
invoices: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
send: false,
|
||||
record_payment: false,
|
||||
export: false,
|
||||
},
|
||||
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
||||
email: { view: false, send: false, configure_account: false },
|
||||
reminders: {
|
||||
view_own: true,
|
||||
view_all: false,
|
||||
create: true,
|
||||
edit_own: true,
|
||||
edit_all: false,
|
||||
assign_others: false,
|
||||
},
|
||||
calendar: { connect: false, view_events: false },
|
||||
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||||
document_templates: { view: false, generate: false, manage: false },
|
||||
yachts: { view: false, create: false, edit: false, delete: false, transfer: false },
|
||||
companies: { view: false, create: false, edit: false, delete: false },
|
||||
memberships: { view: false, manage: false },
|
||||
reservations: { view: false, create: false, activate: false, cancel: false },
|
||||
admin: {
|
||||
manage_users: false,
|
||||
view_audit_log: false,
|
||||
manage_settings: false,
|
||||
manage_webhooks: false,
|
||||
manage_reports: false,
|
||||
manage_custom_fields: false,
|
||||
manage_forms: false,
|
||||
manage_tags: false,
|
||||
system_backup: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: false },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Port Definitions ────────────────────────────────────────────────────────
|
||||
|
||||
const PORT_DEFINITIONS: Array<{
|
||||
@@ -516,6 +600,15 @@ async function seed() {
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'residential_partner',
|
||||
description:
|
||||
'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.',
|
||||
permissions: RESIDENTIAL_PARTNER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const role of systemRoles) {
|
||||
|
||||
@@ -611,9 +611,17 @@ export async function changeInterestStage(
|
||||
// Block egregious skips. The transition table allows reasonable forward
|
||||
// jumps (e.g. open → eoi_sent) while rejecting things like completed → open
|
||||
// or open → contract_signed. Same-stage no-ops are allowed.
|
||||
if (!canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
||||
// Override (sales-rep manual fix) bypasses the table — the route handler
|
||||
// gates this on the `interests.override_stage` permission and requires
|
||||
// a reason, recorded in the audit log below.
|
||||
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
||||
throw new ValidationError(
|
||||
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}".`,
|
||||
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`,
|
||||
);
|
||||
}
|
||||
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
|
||||
throw new ValidationError(
|
||||
'Override requires a reason (min 5 chars) explaining the manual stage change.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ export const updateInterestSchema = createInterestSchema
|
||||
export const changeStageSchema = z.object({
|
||||
pipelineStage: z.enum(PIPELINE_STAGES),
|
||||
reason: z.string().optional(),
|
||||
/** Bypass the canTransitionStage transition table. Requires the caller
|
||||
* to hold the `interests.override_stage` permission. Reason becomes
|
||||
* required when override=true (recorded in the audit log). */
|
||||
override: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -309,6 +309,7 @@ export function makeFullPermissions(): RolePermissions {
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
@@ -387,6 +388,7 @@ export function makeViewerPermissions(): RolePermissions {
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
@@ -465,6 +467,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: false,
|
||||
},
|
||||
@@ -543,6 +546,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user