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;
queueStats: QueueStats | null;
isLoading: boolean;
onClaimNext: () => Promise<void>;
onExtendLock: (submissionId: string) => Promise<boolean>;
onReleaseLock: (submissionId: string) => Promise<boolean>;
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 (
<div className="flex flex-col gap-2 min-w-[200px]">
<Button
size="lg"
onClick={onClaimNext}
disabled={isLoading || (queueStats?.pendingCount === 0)}
className="w-full"
>
<Lock className="w-4 h-4 mr-2" />
Claim Next Submission
</Button>
<div className="text-sm text-muted-foreground">
No submission currently claimed. Claim a submission below to start reviewing.
</div>
</div>
);
}

View File

@@ -132,7 +132,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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}

View File

@@ -101,8 +101,13 @@ export const QueueItem = memo(({
const handleClaim = useCallback(async () => {
setIsClaiming(true);
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 (

View File

@@ -12,6 +12,7 @@ export interface ModerationActionsConfig {
user: User | null;
onActionStart: (itemId: string) => void;
onActionComplete: () => void;
currentLockSubmissionId?: string | null;
}
/**
@@ -45,30 +46,65 @@ 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) {
// 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
.from('photos')
.select('id')
.eq('submission_id', item.id);
if (!existingPhotos || existingPhotos.length === 0) {
const photoRecords = photoSubmission.items.map((photoItem: any) => ({
entity_id: photoSubmission.entity_id,
entity_type: photoSubmission.entity_type,
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: photoSubmission.submission_id,
submitted_by: photoSubmission.submission?.user_id,
submission_id: item.id,
submitted_by: typedPhotoSubmission.submission?.user_id,
approved_by: user?.id,
approved_at: new Date().toISOString(),
}));
@@ -76,7 +112,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
await supabase.from('photos').insert(photoRecords);
}
}
}
// Check for submission items
const { data: submissionItems } = await supabase

View File

@@ -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<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
// Claim a specific submission (CRM-style claim any)
const extendLock = useCallback(async (submissionId: string): Promise<boolean> => {
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<boolean> => {
@@ -474,7 +433,6 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
currentLock, // Exposed for reactive UI updates
queueStats,
isLoading,
claimNext,
claimSubmission,
extendLock,
releaseLock,

View File

@@ -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 {