feat: Show moderation notes and allow reversing decisions

This commit is contained in:
gpt-engineer-app[bot]
2025-09-29 02:10:09 +00:00
parent 11bce74d1c
commit ec81a89bec

View File

@@ -28,6 +28,14 @@ interface ModerationItem {
}; };
entity_name?: string; entity_name?: string;
park_name?: string; park_name?: string;
reviewed_at?: string;
reviewed_by?: string;
reviewer_notes?: string;
reviewer_profile?: {
username: string;
display_name?: string;
avatar_url?: string;
};
} }
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
@@ -107,6 +115,8 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
photos, photos,
park_id, park_id,
ride_id, ride_id,
moderated_at,
moderated_by,
parks:park_id ( parks:park_id (
name name
), ),
@@ -135,7 +145,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
submission_type, submission_type,
created_at, created_at,
user_id, user_id,
status status,
reviewed_at,
reviewer_id,
reviewer_notes
`) `)
.in('status', submissionStatuses); .in('status', submissionStatuses);
@@ -193,11 +206,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
submissions = submissionsWithEntities; submissions = submissionsWithEntities;
} }
// Get unique user IDs to fetch profiles // Get unique user IDs to fetch profiles (including reviewers)
const userIds = [ const userIds = [
...reviews.map(r => r.user_id), ...reviews.map(r => r.user_id),
...submissions.map(s => s.user_id) ...submissions.map(s => s.user_id),
]; ...reviews.filter(r => r.moderated_by).map(r => r.moderated_by),
...submissions.filter(s => s.reviewer_id).map(s => s.reviewer_id)
].filter((id, index, arr) => id && arr.indexOf(id) === index); // Remove duplicates and nulls
// Fetch profiles for all users with avatars // Fetch profiles for all users with avatars
const { data: profiles } = await supabase const { data: profiles } = await supabase
@@ -230,6 +245,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
user_profile: profileMap.get(review.user_id), user_profile: profileMap.get(review.user_id),
entity_name, entity_name,
park_name, park_name,
reviewed_at: review.moderated_at,
reviewed_by: review.moderated_by,
reviewer_notes: (review as any).reviewer_notes,
reviewer_profile: review.moderated_by ? profileMap.get(review.moderated_by) : undefined,
}; };
}), }),
...submissions.map(submission => ({ ...submissions.map(submission => ({
@@ -243,6 +262,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
user_profile: profileMap.get(submission.user_id), user_profile: profileMap.get(submission.user_id),
entity_name: (submission as any).entity_name, entity_name: (submission as any).entity_name,
park_name: (submission as any).park_name, park_name: (submission as any).park_name,
reviewed_at: submission.reviewed_at,
reviewed_by: submission.reviewer_id,
reviewer_notes: submission.reviewer_notes,
reviewer_profile: submission.reviewer_id ? profileMap.get(submission.reviewer_id) : undefined,
})), })),
]; ];
@@ -850,6 +873,73 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
</> </>
)} )}
{/* Reviewer Information for approved/rejected items */}
{(item.status === 'approved' || item.status === 'rejected') && (item.reviewed_at || item.reviewer_notes) && (
<div className="space-y-3 pt-4 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="w-4 h-4" />
<span>Reviewed {item.reviewed_at ? format(new Date(item.reviewed_at), 'MMM d, yyyy HH:mm') : 'recently'}</span>
{item.reviewer_profile && (
<>
<span>by</span>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={item.reviewer_profile.avatar_url} />
<AvatarFallback className="text-xs">
{(item.reviewer_profile.display_name || item.reviewer_profile.username)?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium">
{item.reviewer_profile.display_name || item.reviewer_profile.username}
</span>
</div>
</>
)}
</div>
{item.reviewer_notes && (
<div className="bg-muted/30 p-3 rounded-lg">
<p className="text-sm font-medium mb-1">Reviewer Notes:</p>
<p className="text-sm text-muted-foreground">{item.reviewer_notes}</p>
</div>
)}
{/* Reverse Decision Buttons */}
<div className="space-y-2">
<Label className="text-sm">Reverse Decision</Label>
<Textarea
placeholder="Add notes about reversing this decision..."
value={notes[`reverse-${item.id}`] || ''}
onChange={(e) => setNotes(prev => ({ ...prev, [`reverse-${item.id}`]: e.target.value }))}
rows={2}
/>
<div className="flex gap-2">
{item.status === 'approved' && (
<Button
variant="destructive"
onClick={() => handleModerationAction(item, 'rejected', notes[`reverse-${item.id}`])}
disabled={actionLoading === item.id}
className="flex-1"
>
<XCircle className="w-4 h-4 mr-2" />
Change to Rejected
</Button>
)}
{item.status === 'rejected' && (
<Button
onClick={() => handleModerationAction(item, 'approved', notes[`reverse-${item.id}`])}
disabled={actionLoading === item.id}
className="flex-1"
>
<CheckCircle className="w-4 h-4 mr-2" />
Change to Approved
</Button>
)}
</div>
</div>
</div>
)}
{/* Delete button for rejected submissions (admin/superadmin only) */} {/* Delete button for rejected submissions (admin/superadmin only) */}
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin() || isSuperuser()) && ( {item.status === 'rejected' && item.type === 'content_submission' && (isAdmin() || isSuperuser()) && (
<div className="pt-2"> <div className="pt-2">