diff --git a/src/components/moderation/EditHistoryAccordion.tsx b/src/components/moderation/EditHistoryAccordion.tsx index bd8b552b..9d9a8398 100644 --- a/src/components/moderation/EditHistoryAccordion.tsx +++ b/src/components/moderation/EditHistoryAccordion.tsx @@ -12,11 +12,15 @@ interface EditHistoryRecord { id: string; item_id: string; edited_at: string; - previous_data: Record; - new_data: Record; edit_reason: string | null; changed_fields: string[]; - profiles?: { + field_changes?: Array<{ + id: string; + field_name: string; + old_value: string | null; + new_value: string | null; + }>; + editor?: { username: string; avatar_url?: string | null; } | null; @@ -44,11 +48,15 @@ export function EditHistoryAccordion({ submissionId }: EditHistoryAccordionProps id, item_id, edited_at, - previous_data, - new_data, edit_reason, changed_fields, - profiles:edited_by ( + field_changes:item_field_changes( + id, + field_name, + old_value, + new_value + ), + editor:profiles!item_edit_history_edited_by_fkey( username, avatar_url ) @@ -111,19 +119,30 @@ export function EditHistoryAccordion({ submissionId }: EditHistoryAccordionProps
- {editHistory.map((entry: EditHistoryRecord) => ( - - ))} + {editHistory.map((entry: EditHistoryRecord) => { + // Transform relational field_changes into beforeData/afterData objects + const beforeData: Record = {}; + const afterData: Record = {}; + + entry.field_changes?.forEach(change => { + beforeData[change.field_name] = change.old_value; + afterData[change.field_name] = change.new_value; + }); + + return ( + + ); + })}
diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index a2e4a867..ba186e5d 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -1290,13 +1290,37 @@ export async function editSubmissionItem( if (updateError) throw updateError; // Phase 4: Record edit history - const { error: historyError } = await supabase + const { data: historyData, error: historyError } = await supabase .from('item_edit_history') .insert({ item_id: itemId, - editor_id: userId, - changes: changes, - }); + edited_by: userId, + changed_fields: Object.keys(changes), + edit_reason: 'Direct edit by moderator', + }) + .select('id') + .single(); + + // Insert field changes relationally (NO JSON!) + if (!historyError && historyData) { + const fieldChanges = Object.entries(changes).map(([fieldName, change]: [string, any]) => ({ + edit_history_id: historyData.id, + field_name: fieldName, + old_value: String(change.old ?? ''), + new_value: String(change.new ?? ''), + })); + + const { error: fieldChangesError } = await supabase + .from('item_field_changes') + .insert(fieldChanges); + + if (fieldChangesError) { + handleNonCriticalError(fieldChangesError, { + action: 'Record Field Changes', + metadata: { editHistoryId: historyData.id } + }); + } + } if (historyError) { handleNonCriticalError(historyError, { @@ -1435,9 +1459,17 @@ export async function fetchEditHistory(itemId: string) { .from('item_edit_history') .select(` id, - changes, + item_id, edited_at, - editor:profiles!item_edit_history_editor_id_fkey ( + edit_reason, + changed_fields, + field_changes:item_field_changes( + id, + field_name, + old_value, + new_value + ), + editor:profiles!item_edit_history_edited_by_fkey( user_id, username, display_name, diff --git a/supabase/migrations/20251105012104_b7fe4059-b251-4568-8912-843131128d1e.sql b/supabase/migrations/20251105012104_b7fe4059-b251-4568-8912-843131128d1e.sql new file mode 100644 index 00000000..9fe69b7c --- /dev/null +++ b/supabase/migrations/20251105012104_b7fe4059-b251-4568-8912-843131128d1e.sql @@ -0,0 +1,65 @@ +-- Fix search_path security issue in superuser_release_all_locks function +CREATE OR REPLACE FUNCTION public.superuser_release_all_locks(p_superuser_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public', 'auth' -- Set immutable search path for security +AS $function$ +DECLARE + v_is_superuser BOOLEAN; + v_released_count INTEGER; + v_released_locks JSONB; +BEGIN + -- Verify caller is actually a superuser + SELECT EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = p_superuser_id + AND role = 'superuser' + ) INTO v_is_superuser; + + IF NOT v_is_superuser THEN + RAISE EXCEPTION 'Unauthorized: Only superusers can release all locks'; + END IF; + + -- Capture all locked submissions for audit + SELECT jsonb_agg( + jsonb_build_object( + 'submission_id', id, + 'assigned_to', assigned_to, + 'locked_until', locked_until, + 'submission_type', submission_type + ) + ) INTO v_released_locks + FROM public.content_submissions + WHERE assigned_to IS NOT NULL + AND locked_until > NOW(); + + -- Release all active locks + UPDATE public.content_submissions + SET + assigned_to = NULL, + assigned_at = NULL, + locked_until = NULL + WHERE assigned_to IS NOT NULL + AND locked_until > NOW() + AND status IN ('pending', 'partially_approved'); + + GET DIAGNOSTICS v_released_count = ROW_COUNT; + + -- Log the bulk release + IF v_released_count > 0 THEN + PERFORM public.log_admin_action( + p_superuser_id, + NULL, + 'submission_locks_bulk_released', + jsonb_build_object( + 'released_count', v_released_count, + 'released_locks', v_released_locks, + 'bulk_operation', true + ) + ); + END IF; + + RETURN v_released_count; +END; +$function$; \ No newline at end of file