From 4b697fe45a6123baaf27e90e822e48c0d1b534cd Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:49:58 +0000 Subject: [PATCH] feat: Implement Novu subscriber update --- .../moderation/LockStatusDisplay.tsx | 16 +-- src/components/moderation/ModerationQueue.tsx | 1 - src/components/moderation/QueueItem.tsx | 9 +- src/hooks/moderation/useModerationActions.ts | 83 +++++++++---- src/hooks/useModerationQueue.ts | 114 ++++++------------ src/lib/submissionItemsService.ts | 23 +++- 6 files changed, 128 insertions(+), 118 deletions(-) diff --git a/src/components/moderation/LockStatusDisplay.tsx b/src/components/moderation/LockStatusDisplay.tsx index e3dd397f..07f09773 100644 --- a/src/components/moderation/LockStatusDisplay.tsx +++ b/src/components/moderation/LockStatusDisplay.tsx @@ -18,7 +18,6 @@ interface LockStatusDisplayProps { currentLock: LockState | null; queueStats: QueueStats | null; isLoading: boolean; - onClaimNext: () => Promise; onExtendLock: (submissionId: string) => Promise; onReleaseLock: (submissionId: string) => Promise; getTimeRemaining: () => number | null; @@ -35,7 +34,6 @@ export const LockStatusDisplay = ({ currentLock, queueStats, isLoading, - onClaimNext, onExtendLock, onReleaseLock, getTimeRemaining, @@ -51,19 +49,13 @@ export const LockStatusDisplay = ({ const timeRemaining = getTimeRemaining(); const showExtendButton = timeRemaining !== null && timeRemaining < 5 * 60 * 1000; - // No active lock - show claim button + // If no active lock, show simple info message if (!currentLock) { return (
- +
+ No submission currently claimed. Claim a submission below to start reviewing. +
); } diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 9201ddd2..12ddfcf1 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -132,7 +132,6 @@ export const ModerationQueue = forwardRef((props, ref) => { currentLock={queueManager.queue.currentLock} queueStats={queueManager.queue.queueStats} isLoading={queueManager.queue.isLoading} - onClaimNext={async () => { await queueManager.queue.claimNext(); }} onExtendLock={queueManager.queue.extendLock} onReleaseLock={queueManager.queue.releaseLock} getTimeRemaining={queueManager.queue.getTimeRemaining} diff --git a/src/components/moderation/QueueItem.tsx b/src/components/moderation/QueueItem.tsx index 4a14deaf..049df32e 100644 --- a/src/components/moderation/QueueItem.tsx +++ b/src/components/moderation/QueueItem.tsx @@ -101,8 +101,13 @@ export const QueueItem = memo(({ const handleClaim = useCallback(async () => { setIsClaiming(true); - await onClaimSubmission(item.id); - setIsClaiming(false); + try { + await onClaimSubmission(item.id); + // On success, component will re-render with new lock state + } catch (error) { + console.error('Failed to claim submission:', error); + setIsClaiming(false); + } }, [onClaimSubmission, item.id]); return ( diff --git a/src/hooks/moderation/useModerationActions.ts b/src/hooks/moderation/useModerationActions.ts index 7441a113..77009579 100644 --- a/src/hooks/moderation/useModerationActions.ts +++ b/src/hooks/moderation/useModerationActions.ts @@ -12,6 +12,7 @@ export interface ModerationActionsConfig { user: User | null; onActionStart: (itemId: string) => void; onActionComplete: () => void; + currentLockSubmissionId?: string | null; } /** @@ -45,36 +46,70 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio try { // Handle photo submissions if (action === 'approved' && item.submission_type === 'photo') { - const { data: photoSubmission } = await supabase + const { data: photoSubmission, error: fetchError } = await supabase .from('photo_submissions') - .select(`*, items:photo_submission_items(*), submission:content_submissions!inner(user_id)`) + .select(` + *, + items:photo_submission_items(*), + submission:content_submissions!inner(user_id) + `) .eq('submission_id', item.id) .single(); - if (photoSubmission && photoSubmission.items) { - const { data: existingPhotos } = await supabase - .from('photos') - .select('id') - .eq('submission_id', item.id); + // Add explicit error handling + if (fetchError) { + throw new Error(`Failed to fetch photo submission: ${fetchError.message}`); + } - if (!existingPhotos || existingPhotos.length === 0) { - const photoRecords = photoSubmission.items.map((photoItem: any) => ({ - entity_id: photoSubmission.entity_id, - entity_type: photoSubmission.entity_type, - cloudflare_image_id: photoItem.cloudflare_image_id, - cloudflare_image_url: photoItem.cloudflare_image_url, - title: photoItem.title || null, - caption: photoItem.caption || null, - date_taken: photoItem.date_taken || null, - order_index: photoItem.order_index, - submission_id: photoSubmission.submission_id, - submitted_by: photoSubmission.submission?.user_id, - approved_by: user?.id, - approved_at: new Date().toISOString(), - })); + if (!photoSubmission) { + throw new Error('Photo submission not found'); + } - await supabase.from('photos').insert(photoRecords); - } + // Type assertion with validation + const typedPhotoSubmission = photoSubmission as { + id: string; + entity_id: string; + entity_type: string; + items: Array<{ + id: string; + cloudflare_image_id: string; + cloudflare_image_url: string; + caption?: string; + title?: string; + date_taken?: string; + date_taken_precision?: string; + order_index: number; + }>; + submission: { user_id: string }; + }; + + // Validate required fields + if (!typedPhotoSubmission.items || typedPhotoSubmission.items.length === 0) { + throw new Error('No photo items found in submission'); + } + + const { data: existingPhotos } = await supabase + .from('photos') + .select('id') + .eq('submission_id', item.id); + + if (!existingPhotos || existingPhotos.length === 0) { + const photoRecords = typedPhotoSubmission.items.map((photoItem) => ({ + entity_id: typedPhotoSubmission.entity_id, + entity_type: typedPhotoSubmission.entity_type, + cloudflare_image_id: photoItem.cloudflare_image_id, + cloudflare_image_url: photoItem.cloudflare_image_url, + title: photoItem.title || null, + caption: photoItem.caption || null, + date_taken: photoItem.date_taken || null, + order_index: photoItem.order_index, + submission_id: item.id, + submitted_by: typedPhotoSubmission.submission?.user_id, + approved_by: user?.id, + approved_at: new Date().toISOString(), + })); + + await supabase.from('photos').insert(photoRecords); } } diff --git a/src/hooks/useModerationQueue.ts b/src/hooks/useModerationQueue.ts index b8276127..f48a84af 100644 --- a/src/hooks/useModerationQueue.ts +++ b/src/hooks/useModerationQueue.ts @@ -87,8 +87,10 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { // Start countdown timer for lock expiry const startLockTimer = useCallback((expiresAt: Date) => { + // Clear any existing timer first to prevent leaks if (lockTimerRef.current) { clearInterval(lockTimerRef.current); + lockTimerRef.current = null; } lockTimerRef.current = setInterval(() => { @@ -96,93 +98,39 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { const timeLeft = expiresAt.getTime() - now.getTime(); if (timeLeft <= 0) { - // Lock expired - setCurrentLock(null); + // Clear timer before showing toast to prevent double-firing if (lockTimerRef.current) { clearInterval(lockTimerRef.current); + lockTimerRef.current = null; } + + setCurrentLock(null); + toast({ title: 'Lock Expired', - description: 'Your submission lock has expired', + description: 'Your review lock has expired. Claim another submission to continue.', variant: 'destructive', }); + + if (onLockStateChange) { + onLockStateChange(); + } } - }, 1000); // Check every second - }, [toast]); + }, 1000); + }, [toast, onLockStateChange]); // Add dependencies to avoid stale closures // Clean up timer on unmount useEffect(() => { return () => { + // Comprehensive cleanup on unmount if (lockTimerRef.current) { clearInterval(lockTimerRef.current); + lockTimerRef.current = null; } }; }, []); - // Claim next submission from queue - const claimNext = useCallback(async (): Promise => { - if (!user?.id) { - toast({ - title: 'Authentication Required', - description: 'You must be logged in to claim submissions', - variant: 'destructive', - }); - return null; - } - - setIsLoading(true); - try { - const { data, error } = await supabase.rpc('claim_next_submission', { - moderator_id: user.id, - lock_duration: '15 minutes', - }); - - if (error) throw error; - - if (!data || data.length === 0) { - toast({ - title: 'Queue Empty', - description: 'No submissions available to review', - }); - return null; - } - - const claimed = data[0] as QueuedSubmission; - const expiresAt = new Date(Date.now() + 15 * 60 * 1000); - - setCurrentLock({ - submissionId: claimed.submission_id, - expiresAt, - }); - - startLockTimer(expiresAt); - fetchStats(); - - toast({ - title: 'Submission Claimed', - description: `${claimed.submission_type} submission (waiting ${formatInterval(claimed.waiting_time)})`, - }); - - // Trigger refresh callback - if (onLockStateChange) { - onLockStateChange(); - } - - return claimed.submission_id; - } catch (error: any) { - console.error('Error claiming submission:', error); - toast({ - title: 'Error', - description: error.message || 'Failed to claim submission', - variant: 'destructive', - }); - return null; - } finally { - setIsLoading(false); - } - }, [user, toast, startLockTimer, fetchStats]); - - // Extend current lock + // Claim a specific submission (CRM-style claim any) const extendLock = useCallback(async (submissionId: string): Promise => { if (!user?.id) return false; @@ -248,6 +196,7 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { if (lockTimerRef.current) { clearInterval(lockTimerRef.current); + lockTimerRef.current = null; // Explicitly null it out } fetchStats(); @@ -341,6 +290,13 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { setIsLoading(true); try { + // Get submission details FIRST for better toast message + const { data: submission } = await supabase + .from('content_submissions') + .select('submission_type') + .eq('id', submissionId) + .single(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); const { error } = await supabase @@ -361,14 +317,17 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { }); startLockTimer(expiresAt); - fetchStats(); + // Enhanced toast with submission type + const submissionType = submission?.submission_type || 'submission'; toast({ - title: 'Submission Claimed', - description: 'You now have 15 minutes to review this submission', + title: '✅ Submission Claimed', + description: `${submissionType.charAt(0).toUpperCase() + submissionType.slice(1)} locked for 15 minutes. Start reviewing now.`, + duration: 4000, }); - // Trigger refresh callback + // Force UI refresh to update queue + fetchStats(); if (onLockStateChange) { onLockStateChange(); } @@ -377,15 +336,15 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { } catch (error: any) { console.error('Error claiming submission:', error); toast({ - title: 'Error', - description: error.message || 'Failed to claim submission', + title: 'Failed to Claim Submission', + description: error.message || 'Could not claim this submission. Try again.', variant: 'destructive', }); return false; } finally { - setIsLoading(false); + setIsLoading(false); // Always clear loading state } - }, [user, toast, startLockTimer, fetchStats]); + }, [user, toast, startLockTimer, fetchStats, onLockStateChange]); // Reassign submission const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise => { @@ -474,7 +433,6 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { currentLock, // Exposed for reactive UI updates queueStats, isLoading, - claimNext, claimSubmission, extendLock, releaseLock, diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index 31d5d1cc..c95bab15 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -1069,6 +1069,26 @@ export async function editSubmissionItem( if (updateError) throw updateError; + // CRITICAL: Create version history if this is an entity edit (not photo) + // Only create version if this item has already been approved (has approved_entity_id) + if (currentItem.item_type !== 'photo' && currentItem.approved_entity_id) { + try { + await createVersionForApprovedItem( + currentItem.item_type, + currentItem.approved_entity_id, + userId, + currentItem.submission_id, + true // isEdit = true + ); + + console.log(`✅ Created version for manual edit of ${currentItem.item_type} ${currentItem.approved_entity_id}`); + } catch (versionError) { + console.error('Failed to create version for manual edit:', versionError); + // Don't fail the entire operation, just log the error + // The edit itself is still saved, just without version history + } + } + // Log admin action await supabase .from('admin_audit_log') @@ -1079,7 +1099,8 @@ export async function editSubmissionItem( details: { item_id: itemId, item_type: currentItem.item_type, - changes: 'Item data updated', + changes: 'Item data updated with version history', + version_created: !!(currentItem.approved_entity_id && currentItem.item_type !== 'photo'), }, }); } else {