feat: Implement Moderation State Machine Integration

This commit is contained in:
gpt-engineer-app[bot]
2025-10-21 13:42:34 +00:00
parent 833408f5ae
commit dafbcc7faf
2 changed files with 577 additions and 39 deletions

View File

@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useReducer } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { handleError } from '@/lib/errorHandler';
import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { moderationReducer, canApprove, canReject, hasActiveLock } from '@/lib/moderationStateMachine';
import { useLockMonitor } from '@/lib/moderation/lockMonitor';
import {
fetchSubmissionItems,
buildDependencyTree,
@@ -47,10 +49,13 @@ export function SubmissionReviewManager({
onOpenChange,
onComplete
}: SubmissionReviewManagerProps) {
// State machine for moderation workflow
const [state, dispatch] = useReducer(moderationReducer, { status: 'idle' });
// UI-specific state (kept separate from state machine)
const [items, setItems] = useState<SubmissionItemWithDeps[]>([]);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [conflicts, setConflicts] = useState<DependencyConflict[]>([]);
const [loading, setLoading] = useState(false);
const [showConflictDialog, setShowConflictDialog] = useState(false);
const [showEscalationDialog, setShowEscalationDialog] = useState(false);
const [showRejectionDialog, setShowRejectionDialog] = useState(false);
@@ -71,14 +76,39 @@ export function SubmissionReviewManager({
const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog;
// Lock monitoring integration
useLockMonitor(state, dispatch, submissionId);
// Auto-claim on mount
useEffect(() => {
if (open && submissionId) {
loadSubmissionItems();
if (open && submissionId && state.status === 'idle') {
handleClaimSubmission();
}
}, [open, submissionId]);
}, [open, submissionId, state.status]);
const handleClaimSubmission = async () => {
dispatch({ type: 'CLAIM_ITEM', payload: { itemId: submissionId } });
try {
// Assume lock is acquired by parent component or moderation queue
const lockExpires = new Date(Date.now() + 15 * 60 * 1000).toISOString();
dispatch({ type: 'LOCK_ACQUIRED', payload: { lockExpires } });
// Load data
dispatch({ type: 'LOAD_DATA' });
await loadSubmissionItems();
// Transition to reviewing state with loaded data (empty array as items are tracked separately)
dispatch({ type: 'DATA_LOADED', payload: { reviewData: [] } });
} catch (error: unknown) {
dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, { action: 'Claim Submission', userId: user?.id });
}
};
const loadSubmissionItems = async () => {
setLoading(true);
// State machine already transitioned via handleClaimSubmission
// This just handles the data fetching
try {
const { supabase } = await import('@/integrations/supabase/client');
@@ -110,13 +140,7 @@ export function SubmissionReviewManager({
.map(item => item.id);
setSelectedItemIds(new Set(pendingIds));
} catch (error: unknown) {
handleError(error, {
action: 'Load Submission Items',
userId: user?.id,
metadata: { submissionId, submissionType }
});
} finally {
setLoading(false);
throw error; // Let handleClaimSubmission handle the error
}
};
@@ -137,7 +161,6 @@ export function SubmissionReviewManager({
};
const handleCheckConflicts = async () => {
setLoading(true);
try {
const detectedConflicts = await detectDependencyConflicts(items, Array.from(selectedItemIds));
setConflicts(detectedConflicts);
@@ -154,12 +177,22 @@ export function SubmissionReviewManager({
userId: user?.id,
metadata: { submissionId, selectedCount: selectedItemIds.size }
});
} finally {
setLoading(false);
}
};
const handleApprove = async () => {
// State machine validation
if (!canApprove(state)) {
toast({
title: 'Cannot Approve',
description: state.status === 'lock_expired'
? 'Your lock has expired. Please re-claim this submission.'
: `Invalid state for approval: ${state.status}`,
variant: 'destructive',
});
return;
}
if (!user?.id) {
toast({
title: 'Authentication Required',
@@ -171,7 +204,9 @@ export function SubmissionReviewManager({
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
setLoading(true);
// Transition: reviewing → approving
dispatch({ type: 'START_APPROVAL' });
try {
// Run validation on all selected items
const validationResultsMap = await validateMultipleItems(
@@ -194,7 +229,7 @@ export function SubmissionReviewManager({
if (itemsWithBlockingErrors.length > 0) {
setHasBlockingErrors(true);
setShowValidationBlockerDialog(true);
setLoading(false);
dispatch({ type: 'ERROR', payload: { error: 'Validation failed' } });
return; // Block approval
}
@@ -206,7 +241,7 @@ export function SubmissionReviewManager({
if (itemsWithWarnings.length > 0 && !userConfirmedWarnings) {
setShowWarningConfirmDialog(true);
setLoading(false);
dispatch({ type: 'RESET' }); // Reset to reviewing state
return; // Ask for confirmation
}
@@ -231,6 +266,9 @@ export function SubmissionReviewManager({
throw new Error(data?.error || 'Approval processing failed');
}
// Transition: approving → complete
dispatch({ type: 'COMPLETE', payload: { result: 'approved' } });
toast({
title: 'Items Approved',
description: `Successfully approved ${selectedItemIds.size} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
@@ -255,13 +293,17 @@ export function SubmissionReviewManager({
// If ALL items failed, don't close dialog - show errors
if (allFailed) {
setLoading(false);
dispatch({ type: 'ERROR', payload: { error: 'All items failed' } });
return;
}
// Reset warning confirmation state after approval
setUserConfirmedWarnings(false);
onComplete();
onOpenChange(false);
} catch (error: unknown) {
dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, {
action: 'Approve Submission Items',
userId: user?.id,
@@ -272,8 +314,6 @@ export function SubmissionReviewManager({
hasBlockingErrors
}
});
} finally {
setLoading(false);
}
};
@@ -306,13 +346,28 @@ export function SubmissionReviewManager({
};
const handleReject = async (reason: string, cascade: boolean) => {
// State machine validation
if (!canReject(state)) {
toast({
title: 'Cannot Reject',
description: 'Invalid state for rejection',
variant: 'destructive',
});
return;
}
if (!user?.id) return;
setLoading(true);
// Transition: reviewing → rejecting
dispatch({ type: 'START_REJECTION' });
try {
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
await rejectSubmissionItems(selectedItems, reason, user.id, cascade);
// Transition: rejecting → complete
dispatch({ type: 'COMPLETE', payload: { result: 'rejected' } });
toast({
title: 'Items Rejected',
description: `Successfully rejected ${selectedItems.length} item${selectedItems.length !== 1 ? 's' : ''}`,
@@ -321,6 +376,7 @@ export function SubmissionReviewManager({
onComplete();
onOpenChange(false);
} catch (error: unknown) {
dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, {
action: 'Reject Submission Items',
userId: user?.id,
@@ -331,8 +387,6 @@ export function SubmissionReviewManager({
reason: reason.substring(0, 100)
}
});
} finally {
setLoading(false);
}
};
@@ -346,7 +400,6 @@ export function SubmissionReviewManager({
return;
}
setLoading(true);
try {
const { supabase } = await import('@/integrations/supabase/client');
@@ -388,8 +441,6 @@ export function SubmissionReviewManager({
reason: reason.substring(0, 100)
}
});
} finally {
setLoading(false);
}
};
@@ -415,7 +466,6 @@ export function SubmissionReviewManager({
return;
}
setLoading(true);
try {
if (status === 'approved') {
const { supabase } = await import('@/integrations/supabase/client');
@@ -461,8 +511,6 @@ export function SubmissionReviewManager({
status
}
});
} finally {
setLoading(false);
}
};
@@ -556,8 +604,77 @@ export function SubmissionReviewManager({
);
function ReviewContent() {
// Show loading states based on state machine
if (state.status === 'claiming' || state.status === 'loading_data') {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">
{state.status === 'claiming' ? 'Claiming submission...' : 'Loading items...'}
</p>
</div>
</div>
);
}
// Show error state
if (state.status === 'error') {
return (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{state.error || 'An error occurred while processing this submission'}
<div className="mt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dispatch({ type: 'RESET' });
handleClaimSubmission();
}}
>
Try Again
</Button>
</div>
</AlertDescription>
</Alert>
);
}
// Show lock expired warning
if (state.status === 'lock_expired') {
return (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Your lock on this submission has expired. You need to re-claim it to continue.
<div className="mt-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dispatch({ type: 'RESET' });
handleClaimSubmission();
}}
>
Re-claim Submission
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
</div>
</AlertDescription>
</Alert>
);
}
// Protection 2: UI detection of empty submissions
if (items.length === 0 && !loading) {
if (items.length === 0 && state.status === 'reviewing') {
return (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
@@ -624,7 +741,7 @@ export function SubmissionReviewManager({
<TabsContent value="items" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{items.length === 0 && !loading && (
{items.length === 0 && state.status === 'reviewing' && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
@@ -674,27 +791,34 @@ export function SubmissionReviewManager({
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t">
<Button
onClick={handleCheckConflicts}
disabled={selectedCount === 0 || loading || hasBlockingErrors}
disabled={
selectedCount === 0 ||
!canApprove(state) ||
hasBlockingErrors
}
className="flex-1"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Approve Selected ({selectedCount})
{state.status === 'approving' ? 'Approving...' : `Approve Selected (${selectedCount})`}
</Button>
<Button
onClick={handleRejectSelected}
disabled={selectedCount === 0 || loading}
disabled={
selectedCount === 0 ||
!canReject(state)
}
variant="destructive"
className="flex-1"
>
<XCircle className="w-4 h-4 mr-2" />
Reject Selected
{state.status === 'rejecting' ? 'Rejecting...' : 'Reject Selected'}
</Button>
<Button
onClick={() => setShowEscalationDialog(true)}
variant="outline"
disabled={loading}
disabled={state.status !== 'reviewing'}
>
<ArrowUp className="w-4 h-4 mr-2" />
Escalate