mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 15:11:12 -05:00
feat: Implement Novu subscriber update
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user