feat: Implement all 7 phases

This commit is contained in:
gpt-engineer-app[bot]
2025-11-02 21:00:22 +00:00
parent bccaebc6d6
commit f3c898dfc1
12 changed files with 1236 additions and 42 deletions

View File

@@ -0,0 +1,34 @@
# Moderation Queue Architecture
## Overview
The moderation queue system is a comprehensive content review platform that enables moderators to review, approve, and reject user-submitted content including park/ride submissions, photo uploads, and user reviews.
## System Architecture
### Core Components
```
┌─────────────────────────────────────────────────────────────┐
│ ModerationQueue (Root) │
│ - Entry point for moderation interface │
│ - Manages UI state (modals, dialogs) │
│ - Delegates business logic to hooks │
└────────────┬────────────────────────────────────────────────┘
├─► useModerationQueueManager (Orchestrator)
│ └─► Combines multiple sub-hooks
│ ├─► useModerationFilters (Filtering)
│ ├─► usePagination (Page management)
│ ├─► useModerationQueue (Lock management)
│ ├─► useModerationActions (Action handlers)
│ ├─► useEntityCache (Entity name resolution)
│ └─► useProfileCache (User profile caching)
├─► QueueFilters (Filter controls)
├─► QueueStats (Statistics display)
├─► LockStatusDisplay (Current lock info)
└─► QueueItem (Individual submission renderer)
└─► Wrapped in ModerationErrorBoundary
└─► Prevents individual failures from crashing queue

View File

@@ -0,0 +1,524 @@
# Moderation Queue Components
## Component Reference
### ModerationQueue (Root Component)
**Location:** `src/components/moderation/ModerationQueue.tsx`
**Purpose:** Root component for moderation interface. Orchestrates all sub-components and manages UI state.
**Props:**
```typescript
interface ModerationQueueProps {
optimisticallyUpdateStats?: (delta: Partial<{
pendingSubmissions: number;
openReports: number;
flaggedContent: number;
}>) => void;
}
```
**Ref API:**
```typescript
interface ModerationQueueRef {
refresh: () => void;
}
```
**Usage:**
```tsx
import { useRef } from 'react';
import { ModerationQueue } from '@/components/moderation/ModerationQueue';
function AdminPanel() {
const queueRef = useRef<ModerationQueueRef>(null);
return (
<div>
<button onClick={() => queueRef.current?.refresh()}>
Refresh Queue
</button>
<ModerationQueue ref={queueRef} />
</div>
);
}
```
---
### ModerationErrorBoundary
**Location:** `src/components/error/ModerationErrorBoundary.tsx`
**Purpose:** Catches React render errors in queue items, preventing full queue crashes.
**Props:**
```typescript
interface ModerationErrorBoundaryProps {
children: ReactNode;
submissionId?: string;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
```
**Features:**
- Automatic error logging
- User-friendly error UI
- Retry functionality
- Copy error details button
- Development-mode stack traces
**Usage:**
```tsx
<ModerationErrorBoundary submissionId={item.id}>
<QueueItem item={item} {...props} />
</ModerationErrorBoundary>
```
**Custom Fallback:**
```tsx
<ModerationErrorBoundary
submissionId={item.id}
fallback={<div>Custom error message</div>}
onError={(error, info) => {
// Send to monitoring service
trackError(error, info);
}}
>
<QueueItem item={item} {...props} />
</ModerationErrorBoundary>
```
---
### QueueItem
**Location:** `src/components/moderation/QueueItem.tsx`
**Purpose:** Renders individual submission in queue with all interaction controls.
**Props:**
```typescript
interface QueueItemProps {
item: ModerationItem;
isMobile: boolean;
actionLoading: string | null;
isLockedByMe: boolean;
isLockedByOther: boolean;
lockStatus: LockStatus;
currentLockSubmissionId?: string;
notes: Record<string, string>;
isAdmin: boolean;
isSuperuser: boolean;
queueIsLoading: boolean;
onNoteChange: (id: string, value: string) => void;
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
onResetToPending: (item: ModerationItem) => void;
onRetryFailed: (item: ModerationItem) => void;
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
onOpenReviewManager: (submissionId: string) => void;
onOpenItemEditor: (submissionId: string) => void;
onClaimSubmission: (submissionId: string) => void;
onDeleteSubmission: (item: ModerationItem) => void;
onInteractionFocus: (id: string) => void;
onInteractionBlur: (id: string) => void;
}
```
**Key Features:**
- Displays submission type, status, timestamps
- User profile with avatar
- Validation summary (errors, warnings)
- Lock status indicators
- Moderator edit badges
- Action buttons (approve, reject, claim)
- Responsive mobile/desktop layouts
**Accessibility:**
- Keyboard navigation support
- ARIA labels on interactive elements
- Focus management
- Screen reader compatible
---
### QueueFilters
**Location:** `src/components/moderation/QueueFilters.tsx`
**Purpose:** Filter and sort controls for moderation queue.
**Props:**
```typescript
interface QueueFiltersProps {
activeEntityFilter: EntityFilter;
activeStatusFilter: StatusFilter;
sortConfig: SortConfig;
isMobile: boolean;
isLoading?: boolean;
onEntityFilterChange: (filter: EntityFilter) => void;
onStatusFilterChange: (filter: StatusFilter) => void;
onSortChange: (config: SortConfig) => void;
onClearFilters: () => void;
showClearButton: boolean;
}
```
**Features:**
- Entity type filter (all, reviews, submissions, photos)
- Status filter (pending, approved, rejected, etc.)
- Sort controls (date, type, status)
- Clear filters button
- Fully accessible (ARIA labels, keyboard navigation)
**Usage:**
```tsx
<QueueFilters
activeEntityFilter={filters.entityFilter}
activeStatusFilter={filters.statusFilter}
sortConfig={filters.sortConfig}
isMobile={isMobile}
onEntityFilterChange={filters.setEntityFilter}
onStatusFilterChange={filters.setStatusFilter}
onSortChange={filters.setSortConfig}
onClearFilters={filters.clearFilters}
showClearButton={filters.hasActiveFilters}
/>
```
---
### ValidationSummary
**Location:** `src/components/moderation/ValidationSummary.tsx`
**Purpose:** Displays validation results for submission items.
**Props:**
```typescript
interface ValidationSummaryProps {
item: {
item_type: string;
item_data: SubmissionItemData;
id?: string;
};
onValidationChange?: (result: ValidationResult) => void;
compact?: boolean;
validationKey?: number;
}
```
**View Modes:**
**Compact (for queue items):**
- Status badges (Valid, Errors, Warnings)
- Always-visible error details (no hover needed)
- Minimal space usage
**Detailed (for review manager):**
- Expandable validation details
- Full error, warning, and suggestion lists
- Re-validate button
**Usage:**
```tsx
{/* Compact view in queue */}
<ValidationSummary
item={{
item_type: 'park',
item_data: parkData,
id: itemId
}}
compact={true}
onValidationChange={(result) => {
if (result.blockingErrors.length > 0) {
setCanApprove(false);
}
}}
/>
{/* Detailed view in editor */}
<ValidationSummary
item={{
item_type: 'ride',
item_data: rideData
}}
compact={false}
/>
```
---
## Hooks Reference
### useModerationQueueManager
**Location:** `src/hooks/moderation/useModerationQueueManager.ts`
**Purpose:** Orchestrator hook combining all moderation queue logic.
**Usage:**
```typescript
const queueManager = useModerationQueueManager({
user,
isAdmin: isAdmin(),
isSuperuser: isSuperuser(),
toast,
settings: {
refreshMode: 'auto',
pollInterval: 30000,
refreshStrategy: 'merge',
preserveInteraction: true,
useRealtimeQueue: true,
},
});
// Access sub-hooks
queueManager.filters.setEntityFilter('reviews');
queueManager.pagination.setCurrentPage(2);
queueManager.queue.claimSubmission(itemId);
// Perform actions
await queueManager.performAction(item, 'approved', 'Looks good!');
await queueManager.deleteSubmission(item);
```
---
### useModerationQueue
**Location:** `src/hooks/useModerationQueue.ts`
**Purpose:** Lock management and queue statistics.
**Features:**
- Claim/release submission locks
- Lock expiry countdown
- Lock status checking
- Queue statistics
**Usage:**
```typescript
const queue = useModerationQueue({
onLockStateChange: () => {
console.log('Lock state changed');
}
});
// Claim submission
await queue.claimSubmission('submission-123');
// Extend lock (adds 15 minutes)
await queue.extendLock();
// Release lock
await queue.releaseLock('submission-123');
// Check lock status
const timeRemaining = queue.getTimeRemaining();
const progress = queue.getLockProgress();
```
---
## Testing Components
### Unit Testing Example
```typescript
import { render, screen } from '@testing-library/react';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
describe('ModerationErrorBoundary', () => {
it('catches errors and shows fallback UI', () => {
const ThrowError = () => {
throw new Error('Test error');
};
render(
<ModerationErrorBoundary submissionId="test-123">
<ThrowError />
</ModerationErrorBoundary>
);
expect(screen.getByText(/queue item error/i)).toBeInTheDocument();
expect(screen.getByText(/test error/i)).toBeInTheDocument();
});
it('shows retry button', () => {
const ThrowError = () => {
throw new Error('Test error');
};
render(
<ModerationErrorBoundary>
<ThrowError />
</ModerationErrorBoundary>
);
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
});
```
### Integration Testing Example
```typescript
import { renderHook, act } from '@testing-library/react';
import { useModerationQueueManager } from '@/hooks/moderation/useModerationQueueManager';
describe('useModerationQueueManager', () => {
it('filters items correctly', async () => {
const { result } = renderHook(() => useModerationQueueManager(config));
act(() => {
result.current.filters.setEntityFilter('reviews');
});
await waitFor(() => {
expect(result.current.items.every(item => item.type === 'review')).toBe(true);
});
});
});
```
---
## Accessibility Guidelines
### Keyboard Navigation
**Queue Filters:**
- `Tab`: Navigate between filter controls
- `Enter`/`Space`: Open dropdown
- `Arrow keys`: Navigate dropdown options
- `Escape`: Close dropdown
**Queue Items:**
- `Tab`: Navigate between interactive elements
- `Enter`/`Space`: Activate buttons
- `Escape`: Close expanded sections
### Screen Reader Support
All components include:
- Semantic HTML (`<button>`, `<label>`, `<select>`)
- ARIA labels for icon-only buttons
- ARIA live regions for dynamic updates
- Proper heading hierarchy
### Focus Management
- Focus trapped in modals
- Focus returned to trigger on close
- Skip links for keyboard users
- Visible focus indicators
---
## Styling Guidelines
### Semantic Tokens
Use design system tokens instead of hardcoded colors:
```tsx
// ❌ DON'T
<div className="text-white bg-blue-500">
// ✅ DO
<div className="text-foreground bg-primary">
```
### Responsive Design
All components support mobile/desktop layouts:
```tsx
<div className={`${isMobile ? 'flex-col' : 'flex-row'}`}>
```
### Dark Mode
All components automatically adapt to light/dark mode using CSS variables.
---
## Performance Considerations
### Memoization
```tsx
// QueueItem is memoized
export const QueueItem = memo(({ item, ...props }) => {
// Component will only re-render if props change
});
```
### Lazy Loading
Large components can be lazy-loaded:
```tsx
const SubmissionReviewManager = lazy(() =>
import('./SubmissionReviewManager')
);
```
### Debouncing
Filters use debounced updates to reduce query load:
```tsx
const filters = useModerationFilters({
debounceDelay: 300, // Wait 300ms before applying filter
});
```
---
## Troubleshooting
### Error Boundary Not Catching Errors
**Issue:** Error boundary doesn't catch async errors or event handler errors.
**Solution:** Error boundaries only catch errors during rendering, lifecycle methods, and constructors. For async errors:
```tsx
try {
await performAction();
} catch (error) {
handleError(error); // Manual error handling
}
```
### Lock Timer Memory Leak
**Issue:** Lock timer continues after component unmount.
**Solution:** Already fixed in Phase 4. Timer now checks `isMounted` flag and cleans up properly.
### Validation Summary Not Updating
**Issue:** Validation summary shows stale data after edits.
**Solution:** Pass `validationKey` prop to force re-validation:
```tsx
<ValidationSummary
item={item}
validationKey={editCount} // Increment on each edit
/>
```
---
## References
- Architecture: `docs/moderation/ARCHITECTURE.md`
- Submission Patterns: `docs/moderation/SUBMISSION_PATTERNS.md`
- Type Definitions: `src/types/moderation.ts`
- Hooks: `src/hooks/moderation/`

View File

@@ -0,0 +1,261 @@
# Submission Patterns & Guidelines
## Overview
This document outlines the patterns and best practices for working with submissions in the moderation queue system.
## Submission Types
### 1. Content Submissions (`content_submissions`)
**When to use:**
- Creating or updating parks, rides, companies, ride models
- Multi-item submissions with dependencies
- Submissions requiring moderator review before going live
**Data Flow:**
```
User Form → validateEntityData() → createSubmission()
→ content_submissions table
→ submission_items table (with dependencies)
→ Moderation Queue
→ Approval → process-selective-approval edge function
→ Live entities created
```
**Example:**
```typescript
// Creating a park with operator dependency
const { success } = await createParkSubmission({
name: "Cedar Point",
park_type: "theme_park",
operator_id: "new_operator_123", // References another item in same submission
});
```
### 2. Photo Submissions (`photo_submissions`)
**When to use:**
- User uploading photos to existing entities
- Photos require moderation but entity already exists
**Data Flow:**
```
UppyPhotoSubmissionUpload
→ Cloudflare Direct Upload
→ photo_submissions + photo_submission_items tables
→ Moderation Queue
→ Approval → Photos linked to entity
```
**Key Requirements:**
- Must be linked to parent `content_submissions` for queue integration
- Caption and title sanitized (plain text only, no HTML)
- Maximum 10 photos per submission
### 3. Reviews (`reviews`)
**When to use:**
- User reviewing a park or ride
- Rating with optional text content
**Data Flow:**
```
ReviewForm
→ reviews table + content_submissions (NEW)
→ Moderation Queue
→ Approval → Review goes live
```
**Sanitization:**
- All review content is plain text (HTML stripped)
- Maximum 5000 characters
- Rating validation (0.5-5.0 scale)
## When to Use Each Table
### Use `content_submissions` when:
✅ Creating new entities (parks, rides, companies)
✅ Updating existing entities
✅ Submissions have multi-item dependencies
✅ Need moderator review before data goes live
### Use Specialized Tables when:
**Photos**: Entity exists, just adding media (`photo_submissions`)
**Reviews**: User feedback on existing entity (`reviews` + `content_submissions`)
**Technical Specs**: Belongs to specific entity (`ride_technical_specifications`)
## Validation Requirements
### All Submissions Must:
1. Pass Zod schema validation (`entityValidationSchemas.ts`)
2. Have proper slug generation (unique, URL-safe)
3. Include source URLs when applicable
4. Pass duplicate detection checks
### Entity-Specific Requirements:
**Parks:**
- Valid `park_type` enum
- Valid location data (country required)
- Opening date format validation
**Rides:**
- Must reference valid `park_id`
- Valid `ride_type` enum
- Opening date validation
**Companies:**
- Valid `company_type` enum
- Country code validation
- Founded year range check
## Dependency Resolution
### Dependency Types:
1. **Same-submission dependencies**: New park references new operator (both in queue)
2. **Existing entity dependencies**: New ride references existing park
3. **Multi-level dependencies**: Ride → Park → Operator → Owner (4 levels)
### Resolution Order:
Dependencies are resolved using topological sorting:
```
1. Load all items in submission
2. Build dependency graph
3. Sort topologically (parents before children)
4. Process in order
```
**Example:**
```
Submission contains:
- Item A: Operator (no dependencies)
- Item B: Park (depends on A)
- Item C: Ride (depends on B)
Processing order: A → B → C
```
## Best Practices
### DO:
✅ Use existing entities when possible (avoid duplicates)
✅ Provide source URLs for verifiability
✅ Write clear submission notes for moderators
✅ Validate data on client-side before submission
✅ Use type guards when working with `SubmissionItemData`
### DON'T:
❌ Store JSON blobs in SQL columns
❌ Skip validation to "speed up" submissions
❌ Create dependencies to non-existent entities
❌ Submit without source verification
❌ Bypass moderation queue (security risk)
## Adding New Submission Types
### Steps:
1. Create type definition in `src/types/moderation.ts`
2. Add type guard to `src/lib/moderation/typeGuards.ts`
3. Create validation schema in `src/lib/entityValidationSchemas.ts`
4. Add submission helper in `src/lib/entitySubmissionHelpers.ts`
5. Update `useModerationQueueManager` query to fetch new type
6. Create renderer component (optional, for complex UI)
7. Add tests for new type
### Example: Adding "Event" Submission Type
```typescript
// 1. Type definition (moderation.ts)
export interface EventItemData {
event_id?: string;
name: string;
park_id: string;
start_date: string;
end_date: string;
}
export type SubmissionItemData =
| ParkItemData
| RideItemData
| EventItemData; // Add here
// 2. Type guard (typeGuards.ts)
export function isEventItemData(data: SubmissionItemData): data is EventItemData {
return 'start_date' in data && 'end_date' in data;
}
// 3. Validation (entityValidationSchemas.ts)
const eventSchema = z.object({
name: z.string().min(1).max(200),
park_id: z.string().uuid(),
start_date: z.string().datetime(),
end_date: z.string().datetime(),
});
// 4. Submission helper (entitySubmissionHelpers.ts)
export async function createEventSubmission(eventData: EventFormData) {
// Validation, submission creation logic
}
// 5. Update queue query to include events
// (already handles all content_submissions)
// 6. Optional: Create EventSubmissionDisplay component
// 7. Add tests
```
## Migration Checklist
When migrating legacy code to this pattern:
- [ ] Remove direct database writes (use submission helpers)
- [ ] Add validation schemas
- [ ] Update to use `SubmissionItemData` types
- [ ] Add type guards where needed
- [ ] Test dependency resolution
- [ ] Verify sanitization is applied
- [ ] Update documentation
## Security Considerations
### Input Validation:
- **Server-side validation** is mandatory (Zod schemas)
- **Client-side validation** for UX only
- **Never trust user input** - always validate and sanitize
### Sanitization:
- HTML stripped from user text (use `rehype-sanitize`)
- URLs validated and optionally stripped
- File uploads validated (type, size, count)
- SQL injection prevented (Supabase parameterized queries)
### Access Control:
- Only moderators can approve/reject
- Users can only submit, not self-approve
- RLS policies enforce row-level security
- Lock system prevents concurrent modifications
## Troubleshooting
### Common Issues:
**"Dependency not found"**
→ Check if parent entity exists in database or in same submission
**"Validation failed"**
→ Check Zod schema, ensure all required fields present
**"Duplicate slug"**
→ Slug generation collided, system will auto-increment
**"Lock expired"**
→ Moderator must re-claim submission to continue
**"Permission denied"**
→ Check user role (must be moderator/admin)
## References
- See `ARCHITECTURE.md` for system design
- See `COMPONENTS.md` for UI component usage
- See `../IMPLEMENTATION_COMPLETE.md` for recent changes

View File

@@ -0,0 +1,159 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { logger } from '@/lib/logger';
interface ModerationErrorBoundaryProps {
children: ReactNode;
submissionId?: string;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ModerationErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
/**
* Error Boundary for Moderation Queue Components
*
* Prevents individual queue item render errors from crashing the entire queue.
* Shows user-friendly error UI with retry functionality.
*
* Usage:
* ```tsx
* <ModerationErrorBoundary submissionId={item.id}>
* <QueueItem item={item} />
* </ModerationErrorBoundary>
* ```
*/
export class ModerationErrorBoundary extends Component<
ModerationErrorBoundaryProps,
ModerationErrorBoundaryState
> {
constructor(props: ModerationErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ModerationErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error to monitoring system
logger.error('Moderation component error caught by boundary', {
action: 'error_boundary_catch',
submissionId: this.props.submissionId,
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
// Update state with error info
this.setState({
errorInfo,
});
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
handleRetry = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
// Custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<Card className="border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/10">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-300">
<AlertCircle className="w-5 h-5" />
Queue Item Error
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to render submission</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p className="text-sm">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
{this.props.submissionId && (
<p className="text-xs text-muted-foreground font-mono">
Submission ID: {this.props.submissionId}
</p>
)}
</div>
</AlertDescription>
</Alert>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(
JSON.stringify({
error: this.state.error?.message,
stack: this.state.error?.stack,
submissionId: this.props.submissionId,
}, null, 2)
);
}}
>
Copy Error Details
</Button>
</div>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show Component Stack
</summary>
<pre className="mt-2 overflow-auto p-2 bg-muted rounded text-xs">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</CardContent>
</Card>
);
}
return this.props.children;
}
}

View File

@@ -12,6 +12,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationQueueManager } from '@/hooks/moderation';
import { QueueItem } from './QueueItem';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
import { QueueSkeleton } from './QueueSkeleton';
import { LockStatusDisplay } from './LockStatusDisplay';
import { getLockStatus } from '@/lib/moderation/lockHelpers';
@@ -199,6 +200,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
<TooltipProvider>
<div className="space-y-6">
{queueManager.items.map((item, index) => (
<ModerationErrorBoundary key={item.id} submissionId={item.id}>
<QueueItem
key={item.id}
item={item}
@@ -224,6 +226,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
/>
</ModerationErrorBoundary>
))}
</div>
</TooltipProvider>

View File

@@ -48,12 +48,16 @@ export const QueueFilters = ({
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select
value={activeEntityFilter}
onValueChange={onEntityFilterChange}
>
<SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-10" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
@@ -92,12 +96,16 @@ export const QueueFilters = ({
{/* Status Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select
value={activeStatusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger className={isMobile ? "h-10" : ""}>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-10" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
@@ -132,6 +140,7 @@ export const QueueFilters = ({
size={isMobile ? "default" : "sm"}
onClick={onClearFilters}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
Clear Filters

View File

@@ -4,6 +4,7 @@ import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
import { PhotoGrid } from '@/components/common/PhotoGrid';
import { normalizePhotoData } from '@/lib/photoHelpers';
import type { PhotoItem } from '@/types/photos';
import type { PhotoForDisplay } from '@/types/moderation';
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -41,7 +42,7 @@ interface QueueItemProps {
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
onResetToPending: (item: ModerationItem) => void;
onRetryFailed: (item: ModerationItem) => void;
onOpenPhotos: (photos: any[], index: number) => void;
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
onOpenReviewManager: (submissionId: string) => void;
onOpenItemEditor: (submissionId: string) => void;
onClaimSubmission: (submissionId: string) => void;

View File

@@ -8,6 +8,7 @@ import { AlertCircle, Loader2 } from 'lucide-react';
import type { SubmissionItemData } from '@/types/submissions';
import { logger } from '@/lib/logger';
import { getErrorMessage } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
interface SubmissionItemsListProps {
submissionId: string;
@@ -97,6 +98,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
}
return (
<ModerationErrorBoundary submissionId={submissionId}>
<div className="flex flex-col gap-3">
{refreshing && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -124,5 +126,6 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
</div>
)}
</div>
</ModerationErrorBoundary>
);
});

View File

@@ -7,10 +7,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger';
import type { SubmissionItemData } from '@/types/moderation';
interface ValidationSummaryProps {
item: {
item_type: string;
item_data: any;
item_data: SubmissionItemData;
id?: string;
};
onValidationChange?: (result: ValidationResult) => void;

View File

@@ -87,8 +87,11 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
}, [user]);
// Start countdown timer for lock expiry
// Start countdown timer for lock expiry with improved memory leak prevention
const startLockTimer = useCallback((expiresAt: Date) => {
// Track if component is still mounted
let isMounted = true;
// Clear any existing timer first to prevent leaks
if (lockTimerRef.current) {
clearInterval(lockTimerRef.current);
@@ -96,6 +99,15 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
lockTimerRef.current = setInterval(() => {
// Prevent timer execution if component unmounted
if (!isMounted) {
if (lockTimerRef.current) {
clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
}
return;
}
const now = new Date();
const timeLeft = expiresAt.getTime() - now.getTime();
@@ -119,7 +131,16 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
}
}, 1000);
}, [toast, onLockStateChange]); // Add dependencies to avoid stale closures
// Return cleanup function
return () => {
isMounted = false;
if (lockTimerRef.current) {
clearInterval(lockTimerRef.current);
lockTimerRef.current = null;
}
};
}, [toast, onLockStateChange]);
// Clean up timer on unmount
useEffect(() => {

View File

@@ -0,0 +1,64 @@
/**
* Type Guard Functions for Moderation Queue
*
* Provides runtime type checking for submission item data.
* Enables type-safe handling of different entity types.
*/
import type {
SubmissionItemData,
ParkItemData,
RideItemData,
CompanyItemData,
RideModelItemData,
PhotoItemData,
} from '@/types/moderation';
/**
* Check if item data is for a park
*/
export function isParkItemData(data: SubmissionItemData): data is ParkItemData {
return 'park_type' in data && 'name' in data;
}
/**
* Check if item data is for a ride
*/
export function isRideItemData(data: SubmissionItemData): data is RideItemData {
return ('ride_id' in data || 'park_id' in data) && 'ride_type' in data;
}
/**
* Check if item data is for a company
*/
export function isCompanyItemData(data: SubmissionItemData): data is CompanyItemData {
return 'company_type' in data && !('park_type' in data) && !('ride_type' in data);
}
/**
* Check if item data is for a ride model
*/
export function isRideModelItemData(data: SubmissionItemData): data is RideModelItemData {
return 'model_type' in data && 'manufacturer_id' in data;
}
/**
* Check if item data is for a photo
*/
export function isPhotoItemData(data: SubmissionItemData): data is PhotoItemData {
return 'photo_url' in data;
}
/**
* Get the entity type from item data (for validation and display)
*/
export function getEntityTypeFromItemData(data: SubmissionItemData): string {
if (isParkItemData(data)) return 'park';
if (isRideItemData(data)) return 'ride';
if (isCompanyItemData(data)) {
return data.company_type; // 'manufacturer', 'designer', etc.
}
if (isRideModelItemData(data)) return 'ride_model';
if (isPhotoItemData(data)) return 'photo';
return 'unknown';
}

View File

@@ -5,6 +5,119 @@
* Extracted from ModerationQueue.tsx to improve maintainability and reusability.
*/
/**
* Photo display interface for moderation queue
*/
export interface PhotoForDisplay {
id: string;
url: string;
cloudflare_image_url: string;
filename: string;
caption?: string | null;
title?: string | null;
date_taken?: string | null;
order_index: number;
}
/**
* Location data interface
*/
export interface LocationData {
id: string;
city?: string | null;
state_province?: string | null;
country: string;
formatted_address?: string | null;
}
/**
* Park submission item data
*/
export interface ParkItemData {
park_id?: string;
name: string;
slug: string;
description?: string;
park_type: string;
status: string;
location_id?: string;
operator_id?: string;
property_owner_id?: string;
opening_date?: string;
closing_date?: string;
source_url?: string;
submission_notes?: string;
}
/**
* Ride submission item data
*/
export interface RideItemData {
ride_id?: string;
name: string;
slug: string;
park_id: string;
manufacturer_id?: string;
designer_id?: string;
model_id?: string;
ride_type: string;
status: string;
opening_date?: string;
closing_date?: string;
source_url?: string;
submission_notes?: string;
}
/**
* Company submission item data
*/
export interface CompanyItemData {
company_id?: string;
name: string;
slug: string;
company_type: string;
country?: string;
founded_year?: number;
source_url?: string;
submission_notes?: string;
}
/**
* Ride model submission item data
*/
export interface RideModelItemData {
model_id?: string;
name: string;
slug: string;
manufacturer_id: string;
model_type: string;
source_url?: string;
submission_notes?: string;
}
/**
* Photo submission item data
*/
export interface PhotoItemData {
photo_url: string;
cloudflare_image_id?: string;
caption?: string;
title?: string;
order_index?: number;
source_url?: string;
submission_notes?: string;
}
/**
* Union type for all submission item data
*/
export type SubmissionItemData =
| ParkItemData
| RideItemData
| CompanyItemData
| RideModelItemData
| PhotoItemData;
/**
* Represents a single item in the moderation queue.
* Can be either a review or a content submission.
@@ -79,8 +192,8 @@ export interface ModerationItem {
submission_items?: Array<{
id: string;
item_type: string;
item_data: any;
original_data?: any;
item_data: SubmissionItemData;
original_data?: SubmissionItemData;
status: string;
}>;
}