mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
feat: Implement all 7 phases
This commit is contained in:
34
docs/moderation/ARCHITECTURE.md
Normal file
34
docs/moderation/ARCHITECTURE.md
Normal 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
|
||||||
524
docs/moderation/COMPONENTS.md
Normal file
524
docs/moderation/COMPONENTS.md
Normal 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/`
|
||||||
261
docs/moderation/SUBMISSION_PATTERNS.md
Normal file
261
docs/moderation/SUBMISSION_PATTERNS.md
Normal 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
|
||||||
159
src/components/error/ModerationErrorBoundary.tsx
Normal file
159
src/components/error/ModerationErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
|
|||||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||||
import { useModerationQueueManager } from '@/hooks/moderation';
|
import { useModerationQueueManager } from '@/hooks/moderation';
|
||||||
import { QueueItem } from './QueueItem';
|
import { QueueItem } from './QueueItem';
|
||||||
|
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||||
import { QueueSkeleton } from './QueueSkeleton';
|
import { QueueSkeleton } from './QueueSkeleton';
|
||||||
import { LockStatusDisplay } from './LockStatusDisplay';
|
import { LockStatusDisplay } from './LockStatusDisplay';
|
||||||
import { getLockStatus } from '@/lib/moderation/lockHelpers';
|
import { getLockStatus } from '@/lib/moderation/lockHelpers';
|
||||||
@@ -199,9 +200,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{queueManager.items.map((item, index) => (
|
{queueManager.items.map((item, index) => (
|
||||||
<QueueItem
|
<ModerationErrorBoundary key={item.id} submissionId={item.id}>
|
||||||
key={item.id}
|
<QueueItem
|
||||||
item={item}
|
key={item.id}
|
||||||
|
item={item}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
actionLoading={queueManager.actionLoading}
|
actionLoading={queueManager.actionLoading}
|
||||||
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until)}
|
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until)}
|
||||||
@@ -221,9 +223,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
onOpenItemEditor={handleOpenItemEditor}
|
onOpenItemEditor={handleOpenItemEditor}
|
||||||
onClaimSubmission={queueManager.queue.claimSubmission}
|
onClaimSubmission={queueManager.queue.claimSubmission}
|
||||||
onDeleteSubmission={queueManager.deleteSubmission}
|
onDeleteSubmission={queueManager.deleteSubmission}
|
||||||
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
||||||
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
||||||
/>
|
/>
|
||||||
|
</ModerationErrorBoundary>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ export const QueueFilters = ({
|
|||||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||||
{/* Entity Type Filter */}
|
{/* Entity Type Filter */}
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
<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
|
<Select
|
||||||
value={activeEntityFilter}
|
value={activeEntityFilter}
|
||||||
onValueChange={onEntityFilterChange}
|
onValueChange={onEntityFilterChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
<SelectTrigger
|
||||||
|
id="entity-filter"
|
||||||
|
className={isMobile ? "h-10" : ""}
|
||||||
|
aria-label="Filter by entity type"
|
||||||
|
>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getEntityFilterIcon(activeEntityFilter)}
|
{getEntityFilterIcon(activeEntityFilter)}
|
||||||
@@ -92,12 +96,16 @@ export const QueueFilters = ({
|
|||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
<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
|
<Select
|
||||||
value={activeStatusFilter}
|
value={activeStatusFilter}
|
||||||
onValueChange={onStatusFilterChange}
|
onValueChange={onStatusFilterChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={isMobile ? "h-10" : ""}>
|
<SelectTrigger
|
||||||
|
id="status-filter"
|
||||||
|
className={isMobile ? "h-10" : ""}
|
||||||
|
aria-label="Filter by submission status"
|
||||||
|
>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
@@ -132,6 +140,7 @@ export const QueueFilters = ({
|
|||||||
size={isMobile ? "default" : "sm"}
|
size={isMobile ? "default" : "sm"}
|
||||||
onClick={onClearFilters}
|
onClick={onClearFilters}
|
||||||
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
||||||
|
aria-label="Clear all filters"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
Clear Filters
|
Clear Filters
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { usePhotoSubmissionItems } from '@/hooks/usePhotoSubmissionItems';
|
|||||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
||||||
import { normalizePhotoData } from '@/lib/photoHelpers';
|
import { normalizePhotoData } from '@/lib/photoHelpers';
|
||||||
import type { PhotoItem } from '@/types/photos';
|
import type { PhotoItem } from '@/types/photos';
|
||||||
|
import type { PhotoForDisplay } from '@/types/moderation';
|
||||||
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
|
import { getSubmissionTypeLabel } from '@/lib/moderation/entities';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -41,7 +42,7 @@ interface QueueItemProps {
|
|||||||
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
|
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
|
||||||
onResetToPending: (item: ModerationItem) => void;
|
onResetToPending: (item: ModerationItem) => void;
|
||||||
onRetryFailed: (item: ModerationItem) => void;
|
onRetryFailed: (item: ModerationItem) => void;
|
||||||
onOpenPhotos: (photos: any[], index: number) => void;
|
onOpenPhotos: (photos: PhotoForDisplay[], index: number) => void;
|
||||||
onOpenReviewManager: (submissionId: string) => void;
|
onOpenReviewManager: (submissionId: string) => void;
|
||||||
onOpenItemEditor: (submissionId: string) => void;
|
onOpenItemEditor: (submissionId: string) => void;
|
||||||
onClaimSubmission: (submissionId: string) => void;
|
onClaimSubmission: (submissionId: string) => void;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AlertCircle, Loader2 } from 'lucide-react';
|
|||||||
import type { SubmissionItemData } from '@/types/submissions';
|
import type { SubmissionItemData } from '@/types/submissions';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||||
|
|
||||||
interface SubmissionItemsListProps {
|
interface SubmissionItemsListProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
@@ -97,32 +98,34 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<ModerationErrorBoundary submissionId={submissionId}>
|
||||||
{refreshing && (
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
{refreshing && (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>Refreshing...</span>
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
</div>
|
<span>Refreshing...</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{/* Show regular submission items */}
|
|
||||||
{items.map((item) => (
|
{/* Show regular submission items */}
|
||||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
{items.map((item) => (
|
||||||
<SubmissionChangesDisplay
|
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||||
item={item}
|
<SubmissionChangesDisplay
|
||||||
view={view}
|
item={item}
|
||||||
showImages={showImages}
|
view={view}
|
||||||
submissionId={submissionId}
|
showImages={showImages}
|
||||||
/>
|
submissionId={submissionId}
|
||||||
</div>
|
/>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Show photo submission if exists */}
|
{/* Show photo submission if exists */}
|
||||||
{hasPhotos && (
|
{hasPhotos && (
|
||||||
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||||
<PhotoSubmissionDisplay submissionId={submissionId} />
|
<PhotoSubmissionDisplay submissionId={submissionId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ModerationErrorBoundary>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
|
import { validateEntityData, ValidationResult } from '@/lib/entityValidationSchemas';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
import type { SubmissionItemData } from '@/types/moderation';
|
||||||
|
|
||||||
interface ValidationSummaryProps {
|
interface ValidationSummaryProps {
|
||||||
item: {
|
item: {
|
||||||
item_type: string;
|
item_type: string;
|
||||||
item_data: any;
|
item_data: SubmissionItemData;
|
||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
onValidationChange?: (result: ValidationResult) => void;
|
onValidationChange?: (result: ValidationResult) => void;
|
||||||
|
|||||||
@@ -87,8 +87,11 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Start countdown timer for lock expiry
|
// Start countdown timer for lock expiry with improved memory leak prevention
|
||||||
const startLockTimer = useCallback((expiresAt: Date) => {
|
const startLockTimer = useCallback((expiresAt: Date) => {
|
||||||
|
// Track if component is still mounted
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
// Clear any existing timer first to prevent leaks
|
// Clear any existing timer first to prevent leaks
|
||||||
if (lockTimerRef.current) {
|
if (lockTimerRef.current) {
|
||||||
clearInterval(lockTimerRef.current);
|
clearInterval(lockTimerRef.current);
|
||||||
@@ -96,6 +99,15 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lockTimerRef.current = setInterval(() => {
|
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 now = new Date();
|
||||||
const timeLeft = expiresAt.getTime() - now.getTime();
|
const timeLeft = expiresAt.getTime() - now.getTime();
|
||||||
|
|
||||||
@@ -119,7 +131,16 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 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
|
// Clean up timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
64
src/lib/moderation/typeGuards.ts
Normal file
64
src/lib/moderation/typeGuards.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -5,6 +5,119 @@
|
|||||||
* Extracted from ModerationQueue.tsx to improve maintainability and reusability.
|
* 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.
|
* Represents a single item in the moderation queue.
|
||||||
* Can be either a review or a content submission.
|
* Can be either a review or a content submission.
|
||||||
@@ -79,8 +192,8 @@ export interface ModerationItem {
|
|||||||
submission_items?: Array<{
|
submission_items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
item_type: string;
|
item_type: string;
|
||||||
item_data: any;
|
item_data: SubmissionItemData;
|
||||||
original_data?: any;
|
original_data?: SubmissionItemData;
|
||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user