mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:51:13 -05:00
feat: Implement timeline manager
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar, Crown, Unlock
|
AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar, Crown, Unlock
|
||||||
@@ -14,6 +14,7 @@ import { UserAvatar } from '@/components/ui/user-avatar';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import type { ModerationItem } from '@/types/moderation';
|
import type { ModerationItem } from '@/types/moderation';
|
||||||
import { sanitizeURL, sanitizePlainText } from '@/lib/sanitize';
|
import { sanitizeURL, sanitizePlainText } from '@/lib/sanitize';
|
||||||
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
|
||||||
interface QueueItemActionsProps {
|
interface QueueItemActionsProps {
|
||||||
item: ModerationItem;
|
item: ModerationItem;
|
||||||
@@ -64,30 +65,50 @@ export const QueueItemActions = memo(({
|
|||||||
onClaim,
|
onClaim,
|
||||||
onSuperuserReleaseLock
|
onSuperuserReleaseLock
|
||||||
}: QueueItemActionsProps) => {
|
}: QueueItemActionsProps) => {
|
||||||
|
// Error state for retry functionality
|
||||||
|
const [actionError, setActionError] = useState<{
|
||||||
|
message: string;
|
||||||
|
errorId?: string;
|
||||||
|
action: 'approve' | 'reject';
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Memoize all handlers to prevent re-renders
|
// Memoize all handlers to prevent re-renders
|
||||||
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
onNoteChange(item.id, e.target.value);
|
onNoteChange(item.id, e.target.value);
|
||||||
}, [onNoteChange, item.id]);
|
}, [onNoteChange, item.id]);
|
||||||
|
|
||||||
// Debounced handlers to prevent duplicate submissions
|
// Debounced handlers with error tracking
|
||||||
const handleApprove = useDebouncedCallback(
|
const handleApprove = useDebouncedCallback(
|
||||||
() => {
|
async () => {
|
||||||
// Extra guard against race conditions
|
if (actionLoading === item.id) return;
|
||||||
if (actionLoading === item.id) {
|
try {
|
||||||
return;
|
setActionError(null);
|
||||||
|
await onApprove(item, 'approved', notes[item.id]);
|
||||||
|
} catch (error: any) {
|
||||||
|
setActionError({
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
errorId: error.errorId,
|
||||||
|
action: 'approve',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
onApprove(item, 'approved', notes[item.id]);
|
|
||||||
},
|
},
|
||||||
300, // 300ms debounce
|
300,
|
||||||
{ leading: true, trailing: false } // Only fire on first click
|
{ leading: true, trailing: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReject = useDebouncedCallback(
|
const handleReject = useDebouncedCallback(
|
||||||
() => {
|
async () => {
|
||||||
if (actionLoading === item.id) {
|
if (actionLoading === item.id) return;
|
||||||
return;
|
try {
|
||||||
|
setActionError(null);
|
||||||
|
await onApprove(item, 'rejected', notes[item.id]);
|
||||||
|
} catch (error: any) {
|
||||||
|
setActionError({
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
errorId: error.errorId,
|
||||||
|
action: 'reject',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
onApprove(item, 'rejected', notes[item.id]);
|
|
||||||
},
|
},
|
||||||
300,
|
300,
|
||||||
{ leading: true, trailing: false }
|
{ leading: true, trailing: false }
|
||||||
@@ -149,6 +170,40 @@ export const QueueItemActions = memo(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Error Display with Retry */}
|
||||||
|
{actionError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Action Failed: {actionError.action}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">{actionError.message}</p>
|
||||||
|
{actionError.errorId && (
|
||||||
|
<p className="text-xs font-mono bg-destructive/10 px-2 py-1 rounded">
|
||||||
|
Reference ID: {actionError.errorId.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
if (actionError.action === 'approve') handleApprove();
|
||||||
|
else if (actionError.action === 'reject') handleReject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry {actionError.action}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setActionError(null)}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action buttons based on status */}
|
{/* Action buttons based on status */}
|
||||||
{(item.status === 'pending' || item.status === 'flagged') && (
|
{(item.status === 'pending' || item.status === 'flagged') && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { supabase } from '@/lib/supabaseClient';
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage, handleError, isSupabaseConnectionError } from '@/lib/errorHandler';
|
||||||
import { validateMultipleItems } from '@/lib/entityValidationSchemas';
|
import { validateMultipleItems } from '@/lib/entityValidationSchemas';
|
||||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
@@ -27,6 +27,7 @@ export interface ModerationActions {
|
|||||||
deleteSubmission: (item: ModerationItem) => Promise<void>;
|
deleteSubmission: (item: ModerationItem) => Promise<void>;
|
||||||
resetToPending: (item: ModerationItem) => Promise<void>;
|
resetToPending: (item: ModerationItem) => Promise<void>;
|
||||||
retryFailedItems: (item: ModerationItem) => Promise<void>;
|
retryFailedItems: (item: ModerationItem) => Promise<void>;
|
||||||
|
escalateSubmission: (item: ModerationItem, reason: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,18 +322,29 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
return { previousData };
|
return { previousData };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (error: any, variables, context) => {
|
||||||
// Rollback on error
|
// Rollback optimistic update
|
||||||
if (context?.previousData) {
|
if (context?.previousData) {
|
||||||
queryClient.setQueryData(['moderation-queue'], context.previousData);
|
queryClient.setQueryData(['moderation-queue'], context.previousData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error already logged by mutation, just show toast
|
// Enhanced error handling with reference ID and network detection
|
||||||
|
const isNetworkError = isSupabaseConnectionError(error);
|
||||||
|
const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Action Failed',
|
title: isNetworkError ? 'Connection Error' : 'Action Failed',
|
||||||
description: getErrorMessage(error) || `Failed to ${variables.action} content`,
|
description: errorMessage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.error('Moderation action failed', {
|
||||||
|
itemId: variables.item.id,
|
||||||
|
action: variables.action,
|
||||||
|
error: errorMessage,
|
||||||
|
errorId: error.errorId,
|
||||||
|
isNetworkError,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -350,14 +362,34 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for performAction mutation to maintain API compatibility
|
* Wrapper function that handles loading states and error tracking
|
||||||
*/
|
*/
|
||||||
const performAction = useCallback(
|
const performAction = useCallback(
|
||||||
async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => {
|
async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => {
|
||||||
onActionStart(item.id);
|
onActionStart(item.id);
|
||||||
await performActionMutation.mutateAsync({ item, action, moderatorNotes });
|
try {
|
||||||
|
await performActionMutation.mutateAsync({ item, action, moderatorNotes });
|
||||||
|
} catch (error) {
|
||||||
|
const errorId = handleError(error, {
|
||||||
|
action: `Moderation ${action}`,
|
||||||
|
userId: user?.id,
|
||||||
|
metadata: {
|
||||||
|
submissionId: item.id,
|
||||||
|
submissionType: item.submission_type,
|
||||||
|
itemType: item.type,
|
||||||
|
hasSubmissionItems: item.submission_items?.length ?? 0,
|
||||||
|
moderatorNotes: moderatorNotes?.substring(0, 100),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach error ID for UI display
|
||||||
|
const enhancedError = error instanceof Error
|
||||||
|
? Object.assign(error, { errorId })
|
||||||
|
: { message: getErrorMessage(error), errorId };
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onActionStart, performActionMutation]
|
[onActionStart, performActionMutation, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,13 +438,23 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
logger.log(`✅ Submission ${item.id} deleted`);
|
logger.log(`✅ Submission ${item.id} deleted`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Error already handled, just show toast
|
const errorId = handleError(error, {
|
||||||
toast({
|
action: 'Delete Submission',
|
||||||
title: 'Error',
|
userId: user?.id,
|
||||||
description: getErrorMessage(error),
|
metadata: {
|
||||||
variant: 'destructive',
|
submissionId: item.id,
|
||||||
|
submissionType: item.submission_type,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
throw error;
|
|
||||||
|
logger.error('Failed to delete submission', {
|
||||||
|
submissionId: item.id,
|
||||||
|
errorId,
|
||||||
|
});
|
||||||
|
const enhancedError = error instanceof Error
|
||||||
|
? Object.assign(error, { errorId })
|
||||||
|
: { message: getErrorMessage(error), errorId };
|
||||||
|
throw enhancedError;
|
||||||
} finally {
|
} finally {
|
||||||
onActionComplete();
|
onActionComplete();
|
||||||
}
|
}
|
||||||
@@ -455,12 +497,23 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
logger.log(`✅ Submission ${item.id} reset to pending`);
|
logger.log(`✅ Submission ${item.id} reset to pending`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Error already handled, just show toast
|
const errorId = handleError(error, {
|
||||||
toast({
|
action: 'Reset to Pending',
|
||||||
title: 'Reset Failed',
|
userId: user?.id,
|
||||||
description: getErrorMessage(error),
|
metadata: {
|
||||||
variant: 'destructive',
|
submissionId: item.id,
|
||||||
|
submissionType: item.submission_type,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.error('Failed to reset status', {
|
||||||
|
submissionId: item.id,
|
||||||
|
errorId,
|
||||||
|
});
|
||||||
|
const enhancedError = error instanceof Error
|
||||||
|
? Object.assign(error, { errorId })
|
||||||
|
: { message: getErrorMessage(error), errorId };
|
||||||
|
throw enhancedError;
|
||||||
} finally {
|
} finally {
|
||||||
onActionComplete();
|
onActionComplete();
|
||||||
}
|
}
|
||||||
@@ -474,6 +527,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
const retryFailedItems = useCallback(
|
const retryFailedItems = useCallback(
|
||||||
async (item: ModerationItem) => {
|
async (item: ModerationItem) => {
|
||||||
onActionStart(item.id);
|
onActionStart(item.id);
|
||||||
|
let failedItemsCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: failedItems } = await supabase
|
const { data: failedItems } = await supabase
|
||||||
@@ -490,6 +544,8 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
failedItemsCount = failedItems.length;
|
||||||
|
|
||||||
const { data, error, requestId } = await invokeWithTracking(
|
const { data, error, requestId } = await invokeWithTracking(
|
||||||
'process-selective-approval',
|
'process-selective-approval',
|
||||||
{
|
{
|
||||||
@@ -527,17 +583,112 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
|
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Error already handled, just show toast
|
const errorId = handleError(error, {
|
||||||
toast({
|
action: 'Retry Failed Items',
|
||||||
title: 'Retry Failed',
|
userId: user?.id,
|
||||||
description: getErrorMessage(error) || 'Failed to retry items',
|
metadata: {
|
||||||
variant: 'destructive',
|
submissionId: item.id,
|
||||||
|
failedItemsCount,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.error('Failed to retry items', {
|
||||||
|
submissionId: item.id,
|
||||||
|
errorId,
|
||||||
|
});
|
||||||
|
const enhancedError = error instanceof Error
|
||||||
|
? Object.assign(error, { errorId })
|
||||||
|
: { message: getErrorMessage(error), errorId };
|
||||||
|
throw enhancedError;
|
||||||
} finally {
|
} finally {
|
||||||
onActionComplete();
|
onActionComplete();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[toast, onActionStart, onActionComplete]
|
[toast, onActionStart, onActionComplete, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalate submission for admin review
|
||||||
|
* Consolidates escalation logic with comprehensive error handling
|
||||||
|
*/
|
||||||
|
const escalateSubmission = useCallback(
|
||||||
|
async (item: ModerationItem, reason: string) => {
|
||||||
|
if (!user?.id) {
|
||||||
|
toast({
|
||||||
|
title: 'Authentication Required',
|
||||||
|
description: 'You must be logged in to escalate submissions',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionStart(item.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call edge function for email notification
|
||||||
|
const { error: edgeFunctionError, requestId } = await invokeWithTracking(
|
||||||
|
'send-escalation-notification',
|
||||||
|
{
|
||||||
|
submissionId: item.id,
|
||||||
|
escalationReason: reason,
|
||||||
|
escalatedBy: user.id,
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (edgeFunctionError) {
|
||||||
|
// Edge function failed - log and show fallback toast
|
||||||
|
handleError(edgeFunctionError, {
|
||||||
|
action: 'Send escalation notification',
|
||||||
|
userId: user.id,
|
||||||
|
metadata: {
|
||||||
|
submissionId: item.id,
|
||||||
|
reason: reason.substring(0, 100),
|
||||||
|
fallbackUsed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Escalated (Email Failed)',
|
||||||
|
description: 'Submission escalated but notification email could not be sent',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Escalated Successfully',
|
||||||
|
description: `Submission escalated and admin notified${requestId ? ` (${requestId.substring(0, 8)})` : ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
|
||||||
|
|
||||||
|
logger.log(`✅ Submission ${item.id} escalated`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorId = handleError(error, {
|
||||||
|
action: 'Escalate Submission',
|
||||||
|
userId: user.id,
|
||||||
|
metadata: {
|
||||||
|
submissionId: item.id,
|
||||||
|
submissionType: item.submission_type,
|
||||||
|
reason: reason.substring(0, 100),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error('Escalation failed', {
|
||||||
|
submissionId: item.id,
|
||||||
|
errorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-throw to allow UI to show retry option
|
||||||
|
const enhancedError = error instanceof Error
|
||||||
|
? Object.assign(error, { errorId })
|
||||||
|
: { message: getErrorMessage(error), errorId };
|
||||||
|
throw enhancedError;
|
||||||
|
} finally {
|
||||||
|
onActionComplete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user, toast, onActionStart, onActionComplete, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -545,5 +696,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
deleteSubmission,
|
deleteSubmission,
|
||||||
resetToPending,
|
resetToPending,
|
||||||
retryFailedItems,
|
retryFailedItems,
|
||||||
|
escalateSubmission,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class AppError extends Error {
|
|||||||
/**
|
/**
|
||||||
* Check if error is a Supabase connection/API error
|
* Check if error is a Supabase connection/API error
|
||||||
*/
|
*/
|
||||||
function isSupabaseConnectionError(error: unknown): boolean {
|
export function isSupabaseConnectionError(error: unknown): boolean {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
const supabaseError = error as { code?: string; status?: number; message?: string };
|
const supabaseError = error as { code?: string; status?: number; message?: string };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user