feat: Implement Novu subscriber update

This commit is contained in:
gpt-engineer-app[bot]
2025-10-15 16:49:58 +00:00
parent fca235269f
commit 4b697fe45a
6 changed files with 128 additions and 118 deletions

View File

@@ -18,7 +18,6 @@ interface LockStatusDisplayProps {
currentLock: LockState | null; currentLock: LockState | null;
queueStats: QueueStats | null; queueStats: QueueStats | null;
isLoading: boolean; isLoading: boolean;
onClaimNext: () => Promise<void>;
onExtendLock: (submissionId: string) => Promise<boolean>; onExtendLock: (submissionId: string) => Promise<boolean>;
onReleaseLock: (submissionId: string) => Promise<boolean>; onReleaseLock: (submissionId: string) => Promise<boolean>;
getTimeRemaining: () => number | null; getTimeRemaining: () => number | null;
@@ -35,7 +34,6 @@ export const LockStatusDisplay = ({
currentLock, currentLock,
queueStats, queueStats,
isLoading, isLoading,
onClaimNext,
onExtendLock, onExtendLock,
onReleaseLock, onReleaseLock,
getTimeRemaining, getTimeRemaining,
@@ -51,19 +49,13 @@ export const LockStatusDisplay = ({
const timeRemaining = getTimeRemaining(); const timeRemaining = getTimeRemaining();
const showExtendButton = timeRemaining !== null && timeRemaining < 5 * 60 * 1000; const showExtendButton = timeRemaining !== null && timeRemaining < 5 * 60 * 1000;
// No active lock - show claim button // If no active lock, show simple info message
if (!currentLock) { if (!currentLock) {
return ( return (
<div className="flex flex-col gap-2 min-w-[200px]"> <div className="flex flex-col gap-2 min-w-[200px]">
<Button <div className="text-sm text-muted-foreground">
size="lg" No submission currently claimed. Claim a submission below to start reviewing.
onClick={onClaimNext} </div>
disabled={isLoading || (queueStats?.pendingCount === 0)}
className="w-full"
>
<Lock className="w-4 h-4 mr-2" />
Claim Next Submission
</Button>
</div> </div>
); );
} }

View File

@@ -132,7 +132,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
currentLock={queueManager.queue.currentLock} currentLock={queueManager.queue.currentLock}
queueStats={queueManager.queue.queueStats} queueStats={queueManager.queue.queueStats}
isLoading={queueManager.queue.isLoading} isLoading={queueManager.queue.isLoading}
onClaimNext={async () => { await queueManager.queue.claimNext(); }}
onExtendLock={queueManager.queue.extendLock} onExtendLock={queueManager.queue.extendLock}
onReleaseLock={queueManager.queue.releaseLock} onReleaseLock={queueManager.queue.releaseLock}
getTimeRemaining={queueManager.queue.getTimeRemaining} getTimeRemaining={queueManager.queue.getTimeRemaining}

View File

@@ -101,8 +101,13 @@ export const QueueItem = memo(({
const handleClaim = useCallback(async () => { const handleClaim = useCallback(async () => {
setIsClaiming(true); setIsClaiming(true);
try {
await onClaimSubmission(item.id); 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); setIsClaiming(false);
}
}, [onClaimSubmission, item.id]); }, [onClaimSubmission, item.id]);
return ( return (

View File

@@ -12,6 +12,7 @@ export interface ModerationActionsConfig {
user: User | null; user: User | null;
onActionStart: (itemId: string) => void; onActionStart: (itemId: string) => void;
onActionComplete: () => void; onActionComplete: () => void;
currentLockSubmissionId?: string | null;
} }
/** /**
@@ -45,30 +46,65 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
try { try {
// Handle photo submissions // Handle photo submissions
if (action === 'approved' && item.submission_type === 'photo') { if (action === 'approved' && item.submission_type === 'photo') {
const { data: photoSubmission } = await supabase const { data: photoSubmission, error: fetchError } = await supabase
.from('photo_submissions') .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) .eq('submission_id', item.id)
.single(); .single();
if (photoSubmission && photoSubmission.items) { // Add explicit error handling
if (fetchError) {
throw new Error(`Failed to fetch photo submission: ${fetchError.message}`);
}
if (!photoSubmission) {
throw new Error('Photo submission not found');
}
// 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 const { data: existingPhotos } = await supabase
.from('photos') .from('photos')
.select('id') .select('id')
.eq('submission_id', item.id); .eq('submission_id', item.id);
if (!existingPhotos || existingPhotos.length === 0) { if (!existingPhotos || existingPhotos.length === 0) {
const photoRecords = photoSubmission.items.map((photoItem: any) => ({ const photoRecords = typedPhotoSubmission.items.map((photoItem) => ({
entity_id: photoSubmission.entity_id, entity_id: typedPhotoSubmission.entity_id,
entity_type: photoSubmission.entity_type, entity_type: typedPhotoSubmission.entity_type,
cloudflare_image_id: photoItem.cloudflare_image_id, cloudflare_image_id: photoItem.cloudflare_image_id,
cloudflare_image_url: photoItem.cloudflare_image_url, cloudflare_image_url: photoItem.cloudflare_image_url,
title: photoItem.title || null, title: photoItem.title || null,
caption: photoItem.caption || null, caption: photoItem.caption || null,
date_taken: photoItem.date_taken || null, date_taken: photoItem.date_taken || null,
order_index: photoItem.order_index, order_index: photoItem.order_index,
submission_id: photoSubmission.submission_id, submission_id: item.id,
submitted_by: photoSubmission.submission?.user_id, submitted_by: typedPhotoSubmission.submission?.user_id,
approved_by: user?.id, approved_by: user?.id,
approved_at: new Date().toISOString(), approved_at: new Date().toISOString(),
})); }));
@@ -76,7 +112,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
await supabase.from('photos').insert(photoRecords); await supabase.from('photos').insert(photoRecords);
} }
} }
}
// Check for submission items // Check for submission items
const { data: submissionItems } = await supabase const { data: submissionItems } = await supabase

View File

@@ -87,8 +87,10 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
// Start countdown timer for lock expiry // Start countdown timer for lock expiry
const startLockTimer = useCallback((expiresAt: Date) => { const startLockTimer = useCallback((expiresAt: Date) => {
// Clear any existing timer first to prevent leaks
if (lockTimerRef.current) { if (lockTimerRef.current) {
clearInterval(lockTimerRef.current); clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
} }
lockTimerRef.current = setInterval(() => { lockTimerRef.current = setInterval(() => {
@@ -96,93 +98,39 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
const timeLeft = expiresAt.getTime() - now.getTime(); const timeLeft = expiresAt.getTime() - now.getTime();
if (timeLeft <= 0) { if (timeLeft <= 0) {
// Lock expired // Clear timer before showing toast to prevent double-firing
setCurrentLock(null);
if (lockTimerRef.current) { if (lockTimerRef.current) {
clearInterval(lockTimerRef.current); clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
} }
setCurrentLock(null);
toast({ toast({
title: 'Lock Expired', title: 'Lock Expired',
description: 'Your submission lock has expired', description: 'Your review lock has expired. Claim another submission to continue.',
variant: 'destructive', variant: 'destructive',
}); });
if (onLockStateChange) {
onLockStateChange();
} }
}, 1000); // Check every second }
}, [toast]); }, 1000);
}, [toast, onLockStateChange]); // Add dependencies to avoid stale closures
// Clean up timer on unmount // Clean up timer on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
// Comprehensive cleanup on unmount
if (lockTimerRef.current) { if (lockTimerRef.current) {
clearInterval(lockTimerRef.current); clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
} }
}; };
}, []); }, []);
// Claim next submission from queue // Claim a specific submission (CRM-style claim any)
const claimNext = useCallback(async (): Promise<string | null> => {
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
const extendLock = useCallback(async (submissionId: string): Promise<boolean> => { const extendLock = useCallback(async (submissionId: string): Promise<boolean> => {
if (!user?.id) return false; if (!user?.id) return false;
@@ -248,6 +196,7 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
if (lockTimerRef.current) { if (lockTimerRef.current) {
clearInterval(lockTimerRef.current); clearInterval(lockTimerRef.current);
lockTimerRef.current = null; // Explicitly null it out
} }
fetchStats(); fetchStats();
@@ -341,6 +290,13 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
setIsLoading(true); setIsLoading(true);
try { 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 expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const { error } = await supabase const { error } = await supabase
@@ -361,14 +317,17 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}); });
startLockTimer(expiresAt); startLockTimer(expiresAt);
fetchStats();
// Enhanced toast with submission type
const submissionType = submission?.submission_type || 'submission';
toast({ toast({
title: 'Submission Claimed', title: 'Submission Claimed',
description: 'You now have 15 minutes to review this submission', 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) { if (onLockStateChange) {
onLockStateChange(); onLockStateChange();
} }
@@ -377,15 +336,15 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
} catch (error: any) { } catch (error: any) {
console.error('Error claiming submission:', error); console.error('Error claiming submission:', error);
toast({ toast({
title: 'Error', title: 'Failed to Claim Submission',
description: error.message || 'Failed to claim submission', description: error.message || 'Could not claim this submission. Try again.',
variant: 'destructive', variant: 'destructive',
}); });
return false; return false;
} finally { } finally {
setIsLoading(false); setIsLoading(false); // Always clear loading state
} }
}, [user, toast, startLockTimer, fetchStats]); }, [user, toast, startLockTimer, fetchStats, onLockStateChange]);
// Reassign submission // Reassign submission
const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise<boolean> => { const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise<boolean> => {
@@ -474,7 +433,6 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
currentLock, // Exposed for reactive UI updates currentLock, // Exposed for reactive UI updates
queueStats, queueStats,
isLoading, isLoading,
claimNext,
claimSubmission, claimSubmission,
extendLock, extendLock,
releaseLock, releaseLock,

View File

@@ -1069,6 +1069,26 @@ export async function editSubmissionItem(
if (updateError) throw updateError; 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 // Log admin action
await supabase await supabase
.from('admin_audit_log') .from('admin_audit_log')
@@ -1079,7 +1099,8 @@ export async function editSubmissionItem(
details: { details: {
item_id: itemId, item_id: itemId,
item_type: currentItem.item_type, 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 { } else {