feat: Implement dynamic join solution

This commit is contained in:
gpt-engineer-app[bot]
2025-11-03 16:20:28 +00:00
parent 9ae4a7b743
commit c68b88de86
4 changed files with 152 additions and 70 deletions

View File

@@ -41,34 +41,87 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
} }
setError(null); setError(null);
// Fetch submission items with relational data // 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
const { data: itemsData, error: itemsError } = await supabase const { data: itemsData, error: itemsError } = await supabase
.from('submission_items') .from('submission_items')
.select(` .select('*')
*,
park_submission:park_submissions!item_data_id(*),
ride_submission:ride_submissions!item_data_id(*)
`)
.eq('submission_id', submissionId) .eq('submission_id', submissionId)
.order('order_index'); .order('order_index');
if (itemsError) throw itemsError; if (itemsError) throw itemsError;
// Transform to include item_data // Fetch entity data for each item based on item_type
const transformedItems = itemsData?.map(item => { const transformedItems = await Promise.all(
let itemData = {}; (itemsData || []).map(async (item) => {
switch (item.item_type) { if (!item.item_data_id) {
case 'park': return { ...item, item_data: {}, entity_data: null };
itemData = item.park_submission || {}; }
break;
case 'ride': try {
itemData = item.ride_submission || {}; let entityData = null;
break;
default: // Fetch from appropriate table based on item_type
itemData = {}; switch (item.item_type) {
} case 'park': {
return { ...item, item_data: itemData }; const { data } = await supabase
}) || []; .from('park_submissions')
.select('*')
.eq('id', item.item_data_id)
.maybeSingle();
entityData = data;
break;
}
case 'ride': {
const { data } = await supabase
.from('ride_submissions')
.select('*')
.eq('id', item.item_data_id)
.maybeSingle();
entityData = data;
break;
}
case 'manufacturer':
case 'operator':
case 'designer':
case 'property_owner': {
const { data } = await supabase
.from('company_submissions')
.select('*')
.eq('id', item.item_data_id)
.maybeSingle();
entityData = data;
break;
}
case 'photo': {
const { data } = await supabase
.from('photo_submissions')
.select('*')
.eq('id', item.item_data_id)
.maybeSingle();
entityData = data;
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 };
}
})
);
// Check for photo submissions (using array query to avoid 406) // Check for photo submissions (using array query to avoid 406)
const { data: photoData, error: photoError } = await supabase const { data: photoData, error: photoError } = await supabase

View File

@@ -55,42 +55,10 @@ export function buildSubmissionQuery(
) { ) {
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config; const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
// Build base query with all needed data + user profiles (eliminate N+1 query) // Use optimized view with pre-joined profiles and entity data
let query = supabase let query = supabase
.from('content_submissions') .from('moderation_queue_with_entities')
.select(` .select('*');
id,
submission_type,
status,
content,
created_at,
submitted_at,
user_id,
reviewed_at,
reviewer_id,
reviewer_notes,
escalated,
assigned_to,
locked_until,
submitter:profiles!content_submissions_user_id_fkey (
user_id,
username,
display_name,
avatar_url
),
reviewer:profiles!content_submissions_reviewer_id_fkey (
user_id,
username,
display_name,
avatar_url
),
submission_items (
id,
item_type,
item_data,
status
)
`);
// CRITICAL: Multi-level ordering // CRITICAL: Multi-level ordering
// Level 1: Always sort by escalated first (descending) - escalated items always appear at top // Level 1: Always sort by escalated first (descending) - escalated items always appear at top

View File

@@ -118,6 +118,35 @@ export type SubmissionItemData =
| RideModelItemData | RideModelItemData
| PhotoItemData; | PhotoItemData;
/**
* Submission item with resolved entity data from database view
*/
export interface SubmissionItem {
id: string;
item_type: string;
item_data_id: string | null;
action_type: 'create' | 'edit' | 'delete';
status: string;
order_index: number;
depends_on: string | null;
approved_entity_id: string | null;
rejection_reason: string | null;
// Entity data from dynamic join (pre-loaded from view)
entity_data?: {
id: string;
submission_id: string;
name: string;
slug: string;
// Other fields vary by entity type
[key: string]: any;
} | null;
// Legacy support for moderation actions
item_data?: SubmissionItemData;
original_data?: SubmissionItemData;
}
/** /**
* 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.
@@ -135,6 +164,9 @@ export interface ModerationItem {
/** Timestamp when the item was created */ /** Timestamp when the item was created */
created_at: string; created_at: string;
/** Timestamp when the item was submitted */
submitted_at?: string;
/** Timestamp when the item was last updated */ /** Timestamp when the item was last updated */
updated_at?: string; updated_at?: string;
@@ -147,7 +179,23 @@ export interface ModerationItem {
/** Type of submission (e.g., 'park', 'ride', 'review') */ /** Type of submission (e.g., 'park', 'ride', 'review') */
submission_type?: string; submission_type?: string;
/** Profile information of the submitting user */ /** Pre-loaded submitter profile from view */
submitter?: {
user_id: string;
username: string;
display_name?: string;
avatar_url?: string;
};
/** Pre-loaded reviewer profile from view */
reviewer?: {
user_id: string;
username: string;
display_name?: string;
avatar_url?: string;
};
/** Legacy: Profile information of the submitting user */
user_profile?: { user_profile?: {
username: string; username: string;
display_name?: string; display_name?: string;
@@ -172,7 +220,7 @@ export interface ModerationItem {
/** Notes left by the reviewing moderator */ /** Notes left by the reviewing moderator */
reviewer_notes?: string; reviewer_notes?: string;
/** Profile information of the reviewing moderator */ /** Legacy: Profile information of the reviewing moderator */
reviewer_profile?: { reviewer_profile?: {
username: string; username: string;
display_name?: string; display_name?: string;
@@ -191,14 +239,8 @@ export interface ModerationItem {
/** Internal flag indicating item is being removed (optimistic update) */ /** Internal flag indicating item is being removed (optimistic update) */
_removing?: boolean; _removing?: boolean;
/** Sub-items for composite submissions */ /** Pre-loaded submission items with entity data from view */
submission_items?: Array<{ submission_items?: SubmissionItem[];
id: string;
item_type: string;
item_data: SubmissionItemData;
original_data?: SubmissionItemData;
status: string;
}>;
} }
/** /**

View File

@@ -70,22 +70,41 @@ export function hasRideModelId(data: Json): data is { ride_model_id: string } &
} }
/** /**
* Safely get name from item_data * Safely get name from item_data or entity_data
* Works with both Json (legacy) and pre-loaded entity_data from view
*/ */
export function getItemName(data: Json): string { export function getItemName(data: Json | Record<string, any> | null | undefined): string {
if (!data) return 'Unnamed';
// Handle entity_data object (from view)
if (typeof data === 'object' && !Array.isArray(data) && 'name' in data) {
return String(data.name);
}
// Handle Json (legacy)
if (hasName(data)) { if (hasName(data)) {
return data.name; return data.name;
} }
return 'Unnamed'; return 'Unnamed';
} }
/** /**
* Safely get photos from item_data * Safely get photos from item_data or entity_data
*/ */
export function getItemPhotos(data: Json): Array<Record<string, Json>> { export function getItemPhotos(data: Json | Record<string, any> | null | undefined): Array<Record<string, Json>> {
if (!data) return [];
// Handle entity_data object (from view)
if (typeof data === 'object' && !Array.isArray(data) && 'photos' in data && Array.isArray(data.photos)) {
return data.photos as Array<Record<string, Json>>;
}
// Handle Json (legacy)
if (hasPhotos(data)) { if (hasPhotos(data)) {
return data.photos; return data.photos;
} }
return []; return [];
} }