mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:11:12 -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 { 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,9 +200,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
{queueManager.items.map((item, index) => (
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
<ModerationErrorBoundary key={item.id} submissionId={item.id}>
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isMobile={isMobile}
|
||||
actionLoading={queueManager.actionLoading}
|
||||
isLockedByMe={queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until)}
|
||||
@@ -221,9 +223,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
||||
onOpenItemEditor={handleOpenItemEditor}
|
||||
onClaimSubmission={queueManager.queue.claimSubmission}
|
||||
onDeleteSubmission={queueManager.deleteSubmission}
|
||||
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
||||
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
||||
/>
|
||||
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
|
||||
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
|
||||
/>
|
||||
</ModerationErrorBoundary>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,32 +98,34 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{refreshing && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Refreshing...</span>
|
||||
</div>
|
||||
)}
|
||||
<ModerationErrorBoundary submissionId={submissionId}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{refreshing && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Refreshing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show regular submission items */}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view={view}
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Show regular submission items */}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view={view}
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show photo submission if exists */}
|
||||
{hasPhotos && (
|
||||
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<PhotoSubmissionDisplay submissionId={submissionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Show photo submission if exists */}
|
||||
{hasPhotos && (
|
||||
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||
<PhotoSubmissionDisplay submissionId={submissionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModerationErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user