diff --git a/src/components/moderation/SubmissionItemsList.tsx b/src/components/moderation/SubmissionItemsList.tsx index 06efaaa2..25d56356 100644 --- a/src/components/moderation/SubmissionItemsList.tsx +++ b/src/components/moderation/SubmissionItemsList.tsx @@ -48,125 +48,21 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({ } setError(null); - // Fetch submission items with entity data - // Note: entity_data is pre-loaded from the view, but we need to fetch from raw table - // when this component is used standalone + // Use database function to fetch submission items with entity data in one query + // This eliminates N+1 query problem and properly handles RLS/AAL2 checks const { data: itemsData, error: itemsError } = await supabase - .from('submission_items') - .select('*') - .eq('submission_id', submissionId) - .order('order_index'); + .rpc('get_submission_items_with_entities', { + p_submission_id: submissionId + }); if (itemsError) throw itemsError; - // Fetch entity data for each item based on item_type and typed FK columns - const transformedItems = await Promise.all( - (itemsData || []).map(async (item) => { - // Determine which FK column to use based on item_type - let itemDataId: string | null = null; - switch (item.item_type) { - case 'park': - itemDataId = item.park_submission_id; - break; - case 'ride': - itemDataId = item.ride_submission_id; - break; - case 'photo': - case 'photo_edit': - case 'photo_delete': - itemDataId = item.photo_submission_id; - break; - case 'manufacturer': - case 'operator': - case 'designer': - case 'property_owner': - itemDataId = item.company_submission_id; - break; - case 'ride_model': - itemDataId = item.ride_model_submission_id; - break; - case 'milestone': - case 'timeline_event': - itemDataId = item.timeline_event_submission_id; - break; - } - - if (!itemDataId) { - return { ...item, item_data: {}, entity_data: null }; - } - - try { - let entityData = null; - - // Fetch from appropriate table based on item_type - switch (item.item_type) { - case 'park': { - const { data } = await supabase - .from('park_submissions') - .select('*') - .eq('id', itemDataId) - .maybeSingle(); - entityData = data as any; - break; - } - case 'ride': { - const { data } = await supabase - .from('ride_submissions') - .select('*') - .eq('id', itemDataId) - .maybeSingle(); - entityData = data as any; - break; - } - case 'manufacturer': - case 'operator': - case 'designer': - case 'property_owner': { - const { data } = await supabase - .from('company_submissions') - .select('*') - .eq('id', itemDataId) - .maybeSingle(); - entityData = data as any; - break; - } - case 'photo': { - const { data } = await supabase - .from('photo_submissions') - .select('*') - .eq('id', itemDataId) - .maybeSingle(); - entityData = data as any; - break; - } - case 'ride_model': { - const { data } = await supabase - .from('ride_model_submissions') - .select('*') - .eq('id', itemDataId) - .maybeSingle(); - entityData = data as any; - break; - } - default: - entityData = null; - } - - return { - ...item, - item_data: entityData || {}, - entity_data: entityData - }; - } catch (err) { - logger.warn('Failed to fetch entity data for item', { - itemId: item.id, - itemType: item.item_type, - error: getErrorMessage(err) - }); - return { ...item, item_data: {}, entity_data: null }; - } - }) - ); + // Transform to expected format + const transformedItems = (itemsData || []).map((item: any) => ({ + ...item, + item_data: item.entity_data || {}, + entity_data: item.entity_data + })); // Check for photo submissions (using array query to avoid 406) const { data: photoData, error: photoError } = await supabase diff --git a/src/components/moderation/displays/RichCompanyDisplay.tsx b/src/components/moderation/displays/RichCompanyDisplay.tsx index 0301a746..2b96f324 100644 --- a/src/components/moderation/displays/RichCompanyDisplay.tsx +++ b/src/components/moderation/displays/RichCompanyDisplay.tsx @@ -10,7 +10,8 @@ interface RichCompanyDisplayProps { } export function RichCompanyDisplay({ data, actionType, showAllFields = true }: RichCompanyDisplayProps) { - const getCompanyTypeColor = (type: string) => { + const getCompanyTypeColor = (type: string | undefined) => { + if (!type) return 'bg-gray-500'; switch (type.toLowerCase()) { case 'manufacturer': return 'bg-blue-500'; case 'operator': return 'bg-green-500'; @@ -31,7 +32,7 @@ export function RichCompanyDisplay({ data, actionType, showAllFields = true }: R

{data.name}

- {data.company_type?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + {(data.company_type || 'Unknown')?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} {data.person_type && ( diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 19a488a1..11db25ab 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -5612,6 +5612,29 @@ export type Database = { Args: { p_item_data_id: string; p_item_type: string } Returns: Json } + get_submission_items_with_entities: { + Args: { p_submission_id: string } + Returns: { + action_type: string + approved_by: string + company_submission_id: string + created_at: string + depends_on: string + entity_data: Json + id: string + item_type: string + order_index: number + park_submission_id: string + photo_submission_id: string + rejected_by: string + ride_model_submission_id: string + ride_submission_id: string + status: string + submission_id: string + timeline_event_submission_id: string + updated_at: string + }[] + } get_user_management_permissions: { Args: { _user_id: string } Returns: Json diff --git a/supabase/migrations/20251104165531_2a04fb6b-db38-44f7-8a07-10f2ea189b72.sql b/supabase/migrations/20251104165531_2a04fb6b-db38-44f7-8a07-10f2ea189b72.sql new file mode 100644 index 00000000..fd5272e2 --- /dev/null +++ b/supabase/migrations/20251104165531_2a04fb6b-db38-44f7-8a07-10f2ea189b72.sql @@ -0,0 +1,73 @@ +-- Create function to fetch submission items with entity data +-- This replaces the N individual queries with a single efficient query + +CREATE OR REPLACE FUNCTION get_submission_items_with_entities(p_submission_id uuid) +RETURNS TABLE ( + id uuid, + submission_id uuid, + item_type text, + action_type text, + status text, + order_index integer, + depends_on uuid, + park_submission_id uuid, + ride_submission_id uuid, + company_submission_id uuid, + photo_submission_id uuid, + ride_model_submission_id uuid, + timeline_event_submission_id uuid, + approved_by uuid, + rejected_by uuid, + created_at timestamptz, + updated_at timestamptz, + entity_data jsonb +) +SECURITY DEFINER +SET search_path = public +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + si.id, + si.submission_id, + si.item_type, + si.action_type, + si.status, + si.order_index, + si.depends_on, + si.park_submission_id, + si.ride_submission_id, + si.company_submission_id, + si.photo_submission_id, + si.ride_model_submission_id, + si.timeline_event_submission_id, + si.approved_by, + si.rejected_by, + si.created_at, + si.updated_at, + -- Join entity data based on item_type + CASE + WHEN si.item_type = 'park' THEN + (SELECT to_jsonb(ps.*) FROM park_submissions ps WHERE ps.id = si.park_submission_id) + WHEN si.item_type = 'ride' THEN + (SELECT to_jsonb(rs.*) FROM ride_submissions rs WHERE rs.id = si.ride_submission_id) + WHEN si.item_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN + (SELECT to_jsonb(cs.*) FROM company_submissions cs WHERE cs.id = si.company_submission_id) + WHEN si.item_type IN ('photo', 'photo_edit', 'photo_delete') THEN + (SELECT to_jsonb(phs.*) FROM photo_submissions phs WHERE phs.id = si.photo_submission_id) + WHEN si.item_type = 'ride_model' THEN + (SELECT to_jsonb(rms.*) FROM ride_model_submissions rms WHERE rms.id = si.ride_model_submission_id) + ELSE NULL + END AS entity_data + FROM submission_items si + WHERE si.submission_id = p_submission_id + ORDER BY si.order_index; +END; +$$; + +COMMENT ON FUNCTION get_submission_items_with_entities IS +'Fetch submission items with their entity data in a single query. Uses SECURITY DEFINER to access submission tables with proper RLS context from the parent content_submissions access.'; + +-- Grant execute to authenticated users +GRANT EXECUTE ON FUNCTION get_submission_items_with_entities(uuid) TO authenticated; \ No newline at end of file