mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 05:51:12 -05:00
Fix validation and approval issues
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
useQueueQuery,
|
useQueueQuery,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
||||||
|
import { useModerationActions } from "./useModerationActions";
|
||||||
|
|
||||||
import type { ModerationItem, EntityFilter, StatusFilter, LoadingState } from "@/types/moderation";
|
import type { ModerationItem, EntityFilter, StatusFilter, LoadingState } from "@/types/moderation";
|
||||||
|
|
||||||
@@ -275,224 +276,36 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform moderation action (approve/reject)
|
* Use validated action handler from useModerationActions
|
||||||
|
*/
|
||||||
|
const moderationActions = useModerationActions({
|
||||||
|
user,
|
||||||
|
onActionStart: setActionLoading,
|
||||||
|
onActionComplete: () => {
|
||||||
|
setActionLoading(null);
|
||||||
|
refresh();
|
||||||
|
queue.refreshStats();
|
||||||
|
},
|
||||||
|
currentLockSubmissionId: queue.currentLock?.submissionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform moderation action (approve/reject) - delegates to validated handler
|
||||||
*/
|
*/
|
||||||
const performAction = useCallback(
|
const performAction = useCallback(
|
||||||
async (item: ModerationItem, action: "approved" | "rejected", moderatorNotes?: string) => {
|
async (item: ModerationItem, action: "approved" | "rejected", moderatorNotes?: string) => {
|
||||||
if (actionLoading === item.id) return;
|
// Release lock if held
|
||||||
|
|
||||||
setActionLoading(item.id);
|
|
||||||
|
|
||||||
// Check MFA (AAL2) requirement before moderation action
|
|
||||||
if (aal === null) {
|
|
||||||
logger.log('⏳ [QUEUE MANAGER] AAL is null, waiting for authentication status...');
|
|
||||||
toast({
|
|
||||||
title: "Loading Authentication Status",
|
|
||||||
description: "Please wait while we verify your authentication level...",
|
|
||||||
});
|
|
||||||
setActionLoading(null);
|
|
||||||
|
|
||||||
// Retry after 1 second
|
|
||||||
setTimeout(() => {
|
|
||||||
logger.log('🔄 [QUEUE MANAGER] Retrying action after AAL load');
|
|
||||||
performAction(item, action, moderatorNotes);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aal !== 'aal2') {
|
|
||||||
logger.warn('🚫 [QUEUE MANAGER] MFA check failed', {
|
|
||||||
aal,
|
|
||||||
expected: 'aal2',
|
|
||||||
userId: user?.id
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "MFA Verification Required",
|
|
||||||
description: "You must complete multi-factor authentication to perform moderation actions.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setActionLoading(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('✅ [QUEUE MANAGER] MFA check passed', { aal });
|
|
||||||
|
|
||||||
// Calculate stat delta for optimistic update
|
|
||||||
const statDelta: Partial<ModerationStats> = {};
|
|
||||||
|
|
||||||
if (action === 'approved' || action === 'rejected') {
|
|
||||||
statDelta.pendingSubmissions = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistically update stats IMMEDIATELY
|
|
||||||
if (optimisticallyUpdateStats) {
|
|
||||||
optimisticallyUpdateStats(statDelta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const shouldRemove =
|
|
||||||
(filters.statusFilter === "pending" || filters.statusFilter === "flagged") &&
|
|
||||||
(action === "approved" || action === "rejected");
|
|
||||||
|
|
||||||
if (shouldRemove) {
|
|
||||||
setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, _removing: true } : i)));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
|
||||||
recentlyRemovedRef.current.add(item.id);
|
|
||||||
setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000);
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release lock if claimed
|
|
||||||
if (queue.currentLock?.submissionId === item.id) {
|
if (queue.currentLock?.submissionId === item.id) {
|
||||||
await queue.releaseLock(item.id, true); // Silent release
|
await queue.releaseLock(item.id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Use validated action handler
|
||||||
// Handle photo submissions
|
await moderationActions.performAction(item, action, moderatorNotes);
|
||||||
if (action === "approved" && item.submission_type === "photo") {
|
|
||||||
const { data: photoSubmission } = await supabase
|
|
||||||
.from("photo_submissions")
|
|
||||||
.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);
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await supabase.from("photos").insert(photoRecords);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for submission items
|
|
||||||
const { data: submissionItems } = await supabase
|
|
||||||
.from("submission_items")
|
|
||||||
.select("id, status")
|
|
||||||
.eq("submission_id", item.id)
|
|
||||||
.in("status", ["pending", "rejected"]);
|
|
||||||
|
|
||||||
if (submissionItems && submissionItems.length > 0) {
|
|
||||||
if (action === "approved") {
|
|
||||||
await supabase.functions.invoke("process-selective-approval", {
|
|
||||||
body: {
|
|
||||||
itemIds: submissionItems.map((i) => i.id),
|
|
||||||
submissionId: item.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Submission Approved",
|
|
||||||
description: `Successfully processed ${submissionItems.length} item(s)`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else if (action === "rejected") {
|
|
||||||
await supabase
|
|
||||||
.from("submission_items")
|
|
||||||
.update({
|
|
||||||
status: "rejected",
|
|
||||||
rejection_reason: moderatorNotes || "Parent submission rejected",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("submission_id", item.id)
|
|
||||||
.eq("status", "pending");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard update
|
|
||||||
const table = item.type === "review" ? "reviews" : "content_submissions";
|
|
||||||
const statusField = item.type === "review" ? "moderation_status" : "status";
|
|
||||||
const timestampField = item.type === "review" ? "moderated_at" : "reviewed_at";
|
|
||||||
const reviewerField = item.type === "review" ? "moderated_by" : "reviewer_id";
|
|
||||||
|
|
||||||
const updateData: any = {
|
|
||||||
[statusField]: action,
|
|
||||||
[timestampField]: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
updateData[reviewerField] = user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moderatorNotes) {
|
|
||||||
updateData.reviewer_notes = moderatorNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.from(table).update(updateData).eq("id", item.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: `Content ${action}`,
|
|
||||||
description: `The ${item.type} has been ${action}. Version history updated.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh stats to update counts
|
|
||||||
queue.refreshStats();
|
|
||||||
|
|
||||||
// Force cache invalidation before refetching to ensure fresh data
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ['moderation-queue'],
|
|
||||||
exact: false // Invalidate all query variants
|
|
||||||
});
|
|
||||||
await queueQuery.refetch();
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = getErrorMessage(error);
|
|
||||||
console.error("Error moderating content:", errorMsg, error);
|
|
||||||
|
|
||||||
// Revert optimistic update
|
|
||||||
setItems((prev) => {
|
|
||||||
const exists = prev.find((i) => i.id === item.id);
|
|
||||||
if (exists) {
|
|
||||||
return prev.map((i) => (i.id === item.id ? item : i));
|
|
||||||
} else {
|
|
||||||
return [...prev, item];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for RLS/permission errors
|
|
||||||
if (errorMsg.includes('row-level security') ||
|
|
||||||
errorMsg.includes('permission denied') ||
|
|
||||||
errorMsg.includes('policy') ||
|
|
||||||
errorMsg.includes('violates row-level security')) {
|
|
||||||
toast({
|
|
||||||
title: "Permission Denied",
|
|
||||||
description: "You don't have permission to perform this action. MFA verification may be required.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: errorMsg || `Failed to ${action} content`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[actionLoading, filters.statusFilter, queue, user, toast],
|
[moderationActions, queue]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a submission permanently
|
* Delete a submission permanently
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -225,6 +225,27 @@ export const milestoneValidationSchema = z.object({
|
|||||||
path: ['from_value'],
|
path: ['from_value'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHOTO OPERATION SCHEMAS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const photoEditValidationSchema = z.object({
|
||||||
|
photo_id: z.string().uuid('Invalid photo ID'),
|
||||||
|
cloudflare_image_url: z.string().url('Invalid image URL'),
|
||||||
|
caption: z.string().trim().max(500, 'Caption must be less than 500 characters').optional().or(z.literal('')),
|
||||||
|
title: z.string().trim().max(200, 'Title must be less than 200 characters').optional().or(z.literal('')),
|
||||||
|
entity_type: z.string().min(1, 'Entity type is required'),
|
||||||
|
entity_id: z.string().uuid('Invalid entity ID'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const photoDeleteValidationSchema = z.object({
|
||||||
|
photo_id: z.string().uuid('Invalid photo ID'),
|
||||||
|
cloudflare_image_id: z.string().min(1, 'Image ID is required'),
|
||||||
|
cloudflare_image_url: z.string().url('Invalid image URL').optional(),
|
||||||
|
entity_type: z.string().min(1, 'Entity type is required'),
|
||||||
|
entity_id: z.string().uuid('Invalid entity ID'),
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// SCHEMA REGISTRY
|
// SCHEMA REGISTRY
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -238,6 +259,8 @@ export const entitySchemas = {
|
|||||||
property_owner: companyValidationSchema,
|
property_owner: companyValidationSchema,
|
||||||
ride_model: rideModelValidationSchema,
|
ride_model: rideModelValidationSchema,
|
||||||
photo: photoValidationSchema,
|
photo: photoValidationSchema,
|
||||||
|
photo_edit: photoEditValidationSchema,
|
||||||
|
photo_delete: photoDeleteValidationSchema,
|
||||||
milestone: milestoneValidationSchema,
|
milestone: milestoneValidationSchema,
|
||||||
timeline_event: milestoneValidationSchema, // Alias for milestone
|
timeline_event: milestoneValidationSchema, // Alias for milestone
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -156,6 +156,39 @@ export function validateEntityDataStrict(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'photo_edit':
|
||||||
|
if (!data.photo_id) {
|
||||||
|
result.blockingErrors.push('Photo ID is required');
|
||||||
|
}
|
||||||
|
if (!data.entity_type) {
|
||||||
|
result.blockingErrors.push('Entity type is required');
|
||||||
|
}
|
||||||
|
if (!data.entity_id) {
|
||||||
|
result.blockingErrors.push('Entity ID is required');
|
||||||
|
}
|
||||||
|
if (data.caption && data.caption.length > 500) {
|
||||||
|
result.blockingErrors.push('Caption must be less than 500 characters');
|
||||||
|
}
|
||||||
|
if (data.title && data.title.length > 200) {
|
||||||
|
result.blockingErrors.push('Title must be less than 200 characters');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'photo_delete':
|
||||||
|
if (!data.photo_id) {
|
||||||
|
result.blockingErrors.push('Photo ID is required');
|
||||||
|
}
|
||||||
|
if (!data.cloudflare_image_id && !data.photo_id) {
|
||||||
|
result.blockingErrors.push('Photo identifier is required');
|
||||||
|
}
|
||||||
|
if (!data.entity_type) {
|
||||||
|
result.blockingErrors.push('Entity type is required');
|
||||||
|
}
|
||||||
|
if (!data.entity_id) {
|
||||||
|
result.blockingErrors.push('Entity ID is required');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'milestone':
|
case 'milestone':
|
||||||
case 'timeline_event':
|
case 'timeline_event':
|
||||||
if (!data.title?.trim()) {
|
if (!data.title?.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user