diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index 052cfd5f..5893893d 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -79,7 +79,7 @@ interface ParkFormProps { onSubmit: (data: ParkFormData & { operator_id?: string; property_owner_id?: string; - _compositeSubmission?: any; + _compositeSubmission?: import('@/types/composite-submission').ParkCompositeSubmission; }) => Promise; onCancel?: () => void; initialData?: Partial

- {item?.item_type.replace('_', ' ').toUpperCase()}: {item?.item_data.name} + {item?.item_type.replace('_', ' ').toUpperCase()}: { + item && typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) && 'name' in item.item_data + ? String((item.item_data as Record).name) + : 'Unnamed' + }

{conflict.message}

diff --git a/src/components/moderation/DependencyTreeView.tsx b/src/components/moderation/DependencyTreeView.tsx index 0ab33678..20fbb028 100644 --- a/src/components/moderation/DependencyTreeView.tsx +++ b/src/components/moderation/DependencyTreeView.tsx @@ -24,8 +24,10 @@ export function DependencyTreeView({ items }: DependencyTreeViewProps) { }; const getItemLabel = (item: SubmissionItemWithDeps): string => { - const data = item.item_data; - const name = data.name || 'Unnamed'; + const data = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) + ? item.item_data as Record + : {}; + const name = 'name' in data && typeof data.name === 'string' ? data.name : 'Unnamed'; const type = item.item_type.replace('_', ' '); return `${name} (${type})`; }; diff --git a/src/components/moderation/DependencyVisualizer.tsx b/src/components/moderation/DependencyVisualizer.tsx index d29f40a9..3199d74e 100644 --- a/src/components/moderation/DependencyVisualizer.tsx +++ b/src/components/moderation/DependencyVisualizer.tsx @@ -104,7 +104,9 @@ export function DependencyVisualizer({ items, selectedIds }: DependencyVisualize

- {item.item_data.name || 'Unnamed'} + {typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) && 'name' in item.item_data + ? String((item.item_data as Record).name) + : 'Unnamed'}

{item.dependents && item.dependents.length > 0 && (

diff --git a/src/components/moderation/ItemEditDialog.tsx b/src/components/moderation/ItemEditDialog.tsx index 80ffc482..d0417268 100644 --- a/src/components/moderation/ItemEditDialog.tsx +++ b/src/components/moderation/ItemEditDialog.tsx @@ -93,15 +93,21 @@ export function ItemEditDialog({ item, items, open, onOpenChange, onComplete }: }; const handlePhotoSubmit = async (caption: string, credit: string) => { + const itemData = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) + ? item.item_data as Record + : {}; + + const photos = 'photos' in itemData && Array.isArray(itemData.photos) + ? itemData.photos + : []; + const photoData = { - ...item.item_data, - photos: Array.isArray(item.item_data.photos) - ? item.item_data.photos.map((photo: unknown) => ({ - ...(typeof photo === 'object' && photo !== null ? photo : {}), - caption, - credit, - })) - : [], + ...itemData, + photos: photos.map((photo: unknown) => ({ + ...(typeof photo === 'object' && photo !== null ? photo as Record : {}), + caption, + credit, + })), }; await handleSubmit(photoData); }; diff --git a/src/components/moderation/ItemReviewCard.tsx b/src/components/moderation/ItemReviewCard.tsx index 30e7620c..6259a201 100644 --- a/src/components/moderation/ItemReviewCard.tsx +++ b/src/components/moderation/ItemReviewCard.tsx @@ -127,7 +127,7 @@ export function ItemReviewCard({ item, onEdit, onStatusChange, submissionId }: I , id: item.id, }} onValidationChange={handleValidationChange} diff --git a/src/components/moderation/ItemSelectorDialog.tsx b/src/components/moderation/ItemSelectorDialog.tsx index fbbf34e1..b5d8e4d9 100644 --- a/src/components/moderation/ItemSelectorDialog.tsx +++ b/src/components/moderation/ItemSelectorDialog.tsx @@ -87,9 +87,9 @@ export function ItemSelectorDialog({ {item.item_type.replace('_', ' ')} - {item.item_data.name && ( + {typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) && 'name' in item.item_data && ( - {String(item.item_data.name)} + {String((item.item_data as Record).name)} )} {item.dependencies && item.dependencies.length > 0 && ( diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index df86940f..8ac5038b 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -145,7 +145,7 @@ export const ModerationQueue = forwardRef { + const handleDeleteSubmission = useCallback((item: { id: string; submission_type?: string }) => { setConfirmDialog({ open: true, title: 'Delete Submission', @@ -198,7 +198,7 @@ export const ModerationQueue = forwardRef { + const handleOpenPhotos = (photos: PhotoItem[], index: number) => { setSelectedPhotos(photos); setSelectedPhotoIndex(index); setPhotoModalOpen(true); diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index a74e9bb8..9bb48a07 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -605,9 +605,12 @@ export function SubmissionReviewManager({ open={showValidationBlockerDialog} onClose={() => setShowValidationBlockerDialog(false)} blockingErrors={Array.from(validationResults.values()).flatMap(r => r.blockingErrors)} - itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => - i.item_data?.name || i.item_type.replace('_', ' ') - )} + itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => { + const name = typeof i.item_data === 'object' && i.item_data !== null && !Array.isArray(i.item_data) && 'name' in i.item_data + ? String((i.item_data as Record).name) + : i.item_type.replace('_', ' '); + return name; + })} /> r.warnings)} - itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => - i.item_data?.name || i.item_type.replace('_', ' ') - )} + itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => { + const name = typeof i.item_data === 'object' && i.item_data !== null && !Array.isArray(i.item_data) && 'name' in i.item_data + ? String((i.item_data as Record).name) + : i.item_type.replace('_', ' '); + return name; + })} /> & - React.ComponentProps<"div"> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: "line" | "dot" | "dashed"; - nameKey?: string; - labelKey?: string; - } + import('@/types/recharts').TooltipProps >( ( { @@ -115,7 +108,7 @@ const ChartTooltipContent = React.forwardRef< color, nameKey, labelKey, - }: any, + }, ref, ) => { const { config } = useChart(); @@ -163,7 +156,11 @@ const ChartTooltipContent = React.forwardRef< {payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload.fill || item.color; + const indicatorColor = color || + (typeof item.payload === 'object' && item.payload !== null && 'fill' in item.payload + ? (item.payload as Record).fill as string + : undefined) || + item.color; return (

& { - hideIcon?: boolean; - nameKey?: string; - payload?: any[]; - verticalAlign?: "top" | "bottom"; - } + import('@/types/recharts').LegendProps >(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => { const { config } = useChart(); @@ -247,7 +239,7 @@ const ChartLegendContent = React.forwardRef< ref={ref} className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)} > - {payload.map((item: any) => { + {payload.map((item) => { const key = `${nameKey || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); diff --git a/src/hooks/moderation/useEntityCache.ts b/src/hooks/moderation/useEntityCache.ts index 1d9ab80f..e95da361 100644 --- a/src/hooks/moderation/useEntityCache.ts +++ b/src/hooks/moderation/useEntityCache.ts @@ -137,8 +137,8 @@ export function useEntityCache() { } try { - let data: any[] | null = null; - let error: any = null; + let data: unknown[] | null = null; + let error: unknown = null; // Use type-safe table queries switch (type) { @@ -172,18 +172,20 @@ export function useEntityCache() { } if (error) { - logger.error(`Error fetching ${type}:`, error); + logger.error(`Error fetching ${type}:`, { error: getErrorMessage(error as Error) }); return []; } // Cache the fetched entities if (data) { - data.forEach((entity: any) => { - setCached(type, entity.id, entity as EntityTypeMap[T]); + (data as Array>).forEach((entity) => { + if (entity && typeof entity === 'object' && 'id' in entity && 'name' in entity) { + setCached(type, entity.id as string, entity as EntityTypeMap[T]); + } }); } - return data || []; + return (data as EntityTypeMap[T][]) || []; } catch (error: unknown) { logger.error(`Failed to bulk fetch ${type}:`, { error: getErrorMessage(error) }); return []; @@ -194,7 +196,7 @@ export function useEntityCache() { * Fetch and cache related entities based on submission content * Automatically determines which entities to fetch from submission data */ - const fetchRelatedEntities = useCallback(async (submissions: any[]): Promise => { + const fetchRelatedEntities = useCallback(async (submissions: Array<{ content?: Record; submission_type?: string }>): Promise => { const rideIds = new Set(); const parkIds = new Set(); const companyIds = new Set(); @@ -203,20 +205,20 @@ export function useEntityCache() { submissions.forEach(submission => { const content = submission.content; if (content && typeof content === 'object') { - if (content.ride_id) rideIds.add(content.ride_id); - if (content.park_id) parkIds.add(content.park_id); - if (content.company_id) companyIds.add(content.company_id); - if (content.entity_id) { + if (typeof content.ride_id === 'string') rideIds.add(content.ride_id); + if (typeof content.park_id === 'string') parkIds.add(content.park_id); + if (typeof content.company_id === 'string') companyIds.add(content.company_id); + if (typeof content.entity_id === 'string') { if (submission.submission_type === 'ride') rideIds.add(content.entity_id); if (submission.submission_type === 'park') parkIds.add(content.entity_id); - if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type)) { + if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type || '')) { companyIds.add(content.entity_id); } } - if (content.manufacturer_id) companyIds.add(content.manufacturer_id); - if (content.designer_id) companyIds.add(content.designer_id); - if (content.operator_id) companyIds.add(content.operator_id); - if (content.property_owner_id) companyIds.add(content.property_owner_id); + if (typeof content.manufacturer_id === 'string') companyIds.add(content.manufacturer_id); + if (typeof content.designer_id === 'string') companyIds.add(content.designer_id); + if (typeof content.operator_id === 'string') companyIds.add(content.operator_id); + if (typeof content.property_owner_id === 'string') companyIds.add(content.property_owner_id); } }); diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index e62f8448..d8492794 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -4,14 +4,15 @@ import { logger } from './logger'; import { extractCloudflareImageId } from './cloudflareImageUtils'; // Core submission item interface with dependencies -// Type safety for item_data will be added in Phase 5 after fixing components +// NOTE: item_data and original_data use `unknown` because they contain dynamic structures +// that vary by item_type. Use type guards from @/types/submission-item-data.ts to access safely. export interface SubmissionItemWithDeps { id: string; submission_id: string; item_type: string; - item_data: any; // Complex nested structure - will be typed properly in Phase 5 - original_data: any; // Complex nested structure - will be typed properly in Phase 5 + item_data: unknown; // Dynamic structure - use type guards for safe access + original_data?: unknown; // Dynamic structure - use type guards for safe access action_type?: 'create' | 'edit' | 'delete'; status: 'pending' | 'approved' | 'rejected' | 'flagged' | 'skipped'; // Matches ReviewStatus from statuses.ts depends_on: string | null; @@ -43,7 +44,10 @@ export interface ConflictCheckResult { serverVersion?: { last_modified_at: string; last_modified_by: string; - modified_by_profile?: any; + modified_by_profile?: { + username: string; + display_name?: string; + }; } | null; } @@ -110,18 +114,28 @@ export async function detectDependencyConflicts( // Suggest creating parent if (parent.status !== 'rejected') { + const parentData = typeof parent.item_data === 'object' && parent.item_data !== null && !Array.isArray(parent.item_data) + ? parent.item_data as Record + : {}; + const parentName = 'name' in parentData && typeof parentData.name === 'string' ? parentData.name : 'Unnamed'; + suggestions.push({ action: 'create_parent', - label: `Also approve ${parent.item_type}: ${parent.item_data.name}`, + label: `Also approve ${parent.item_type}: ${parentName}`, }); } // Suggest linking to existing entity if (parent.item_type === 'park') { + const parentData = typeof parent.item_data === 'object' && parent.item_data !== null && !Array.isArray(parent.item_data) + ? parent.item_data as Record + : {}; + const parentName = 'name' in parentData && typeof parentData.name === 'string' ? parentData.name : ''; + const { data: parks } = await supabase .from('parks') .select('id, name') - .ilike('name', `%${parent.item_data.name}%`) + .ilike('name', `%${parentName}%`) .limit(3); parks?.forEach(park => { @@ -189,11 +203,15 @@ export async function approveSubmissionItems( try { // Determine if this is an edit by checking for entity_id in item_data + const itemData = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data) + ? item.item_data as Record + : {}; + isEdit = !!( - item.item_data.park_id || - item.item_data.ride_id || - item.item_data.company_id || - item.item_data.ride_model_id + ('park_id' in itemData && itemData.park_id) || + ('ride_id' in itemData && itemData.ride_id) || + ('company_id' in itemData && itemData.company_id) || + ('ride_model_id' in itemData && itemData.ride_model_id) ); // Create the entity based on type with dependency resolution diff --git a/src/types/composite-submission.ts b/src/types/composite-submission.ts new file mode 100644 index 00000000..dd813c22 --- /dev/null +++ b/src/types/composite-submission.ts @@ -0,0 +1,40 @@ +/** + * Type definitions for composite submissions + * Used when creating multiple related entities in a single submission + */ + +import type { TempCompanyData } from './company'; + +/** + * Composite submission structure for park creation with related entities + */ +export interface ParkCompositeSubmission { + park: { + name: string; + slug: string; + description?: string; + park_type: string; + status: string; + opening_date?: string; + closing_date?: string; + location_id?: string; + website_url?: string; + phone?: string; + email?: string; + operator_id?: string | null; + property_owner_id?: string | null; + [key: string]: unknown; + }; + new_operator?: TempCompanyData; + new_property_owner?: TempCompanyData; +} + +/** + * Generic composite submission content type + * Supports any entity type with optional related entities + */ +export interface CompositeSubmissionContent { + [entityKey: string]: { + [key: string]: unknown; + } | TempCompanyData | undefined; +} diff --git a/src/types/recharts.ts b/src/types/recharts.ts new file mode 100644 index 00000000..e9c7d1cb --- /dev/null +++ b/src/types/recharts.ts @@ -0,0 +1,55 @@ +/** + * Type definitions for Recharts payloads + * Provides type-safe alternatives to `any` in chart components + */ + +import type { ReactNode } from 'react'; + +/** + * Generic chart payload item structure + */ +export interface ChartPayloadItem { + value?: number | string; + name?: string; + dataKey?: string; + color?: string; + fill?: string; + payload?: T; + [key: string]: unknown; +} + +/** + * Tooltip content props from Recharts + */ +export interface TooltipProps { + active?: boolean; + payload?: ChartPayloadItem[]; + label?: string | number; + labelFormatter?: (value: unknown, payload: ChartPayloadItem[]) => ReactNode; + formatter?: ( + value: number | string, + name: string, + item: ChartPayloadItem, + index: number, + payload: unknown + ) => ReactNode; + className?: string; + indicator?: 'line' | 'dot' | 'dashed'; + hideLabel?: boolean; + hideIndicator?: boolean; + color?: string; + nameKey?: string; + labelKey?: string; + labelClassName?: string; +} + +/** + * Legend content props from Recharts + */ +export interface LegendProps { + payload?: ChartPayloadItem[]; + className?: string; + hideIcon?: boolean; + nameKey?: string; + verticalAlign?: 'top' | 'bottom'; +} diff --git a/src/types/submission-item-data.ts b/src/types/submission-item-data.ts new file mode 100644 index 00000000..2a611204 --- /dev/null +++ b/src/types/submission-item-data.ts @@ -0,0 +1,113 @@ +/** + * Type-safe helpers for accessing submission item data + * Provides type guards and type narrowing for Json fields + */ + +import type { Json } from '@/integrations/supabase/types'; + +/** + * Base structure that all item_data objects should have + */ +export interface BaseItemData { + name?: string; + slug?: string; + description?: string; + [key: string]: Json; +} + +/** + * Type guard to safely check if Json is an object with a name property + */ +export function hasName(data: Json): data is { name: string } & Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) && 'name' in data && typeof (data as Record).name === 'string'; +} + +/** + * Type guard to check if Json is a photos array + */ +export function hasPhotos(data: Json): data is { photos: Array> } & Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) && 'photos' in data && Array.isArray((data as Record).photos); +} + +/** + * Type guard for manufacturer data + */ +export function hasManufacturer(data: Json): data is { manufacturer_id: string; manufacturer_name: string } & Record { + return ( + typeof data === 'object' && + data !== null && + !Array.isArray(data) && + 'manufacturer_id' in data && + 'manufacturer_name' in data + ); +} + +/** + * Type guard for park data + */ +export function hasParkId(data: Json): data is { park_id: string } & Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) && 'park_id' in data; +} + +/** + * Type guard for ride data + */ +export function hasRideId(data: Json): data is { ride_id: string } & Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) && 'ride_id' in data; +} + +/** + * Type guard for company data + */ +export function hasCompanyId(data: Json): data is { company_id: string } & Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) && 'company_id' in data; +} + +/** + * Type guard for ride model data + */ +export function hasRideModelId(data: Json): data is { ride_model_id: string } & Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) && 'ride_model_id' in data; +} + +/** + * Safely get name from item_data + */ +export function getItemName(data: Json): string { + if (hasName(data)) { + return data.name; + } + return 'Unnamed'; +} + +/** + * Safely get photos from item_data + */ +export function getItemPhotos(data: Json): Array> { + if (hasPhotos(data)) { + return data.photos; + } + return []; +} + +/** + * Convert Json to a record for form usage + * Only use when you need to pass data to form components + */ +export function jsonToRecord(data: Json): Record { + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + return data as Record; + } + return {}; +} + +/** + * Type-safe way to access nested Json properties + */ +export function getProperty(data: Json, key: string): T | undefined { + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + const obj = data as Record; + return obj[key] as T | undefined; + } + return undefined; +}