Fix rollback system

This commit is contained in:
gpt-engineer-app[bot]
2025-10-30 14:12:44 +00:00
parent 36790600b9
commit cc58267c93
4 changed files with 222 additions and 6 deletions

View File

@@ -10,6 +10,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { VersionComparisonDialog } from './VersionComparisonDialog'; import { VersionComparisonDialog } from './VersionComparisonDialog';
import { RollbackDialog } from './RollbackDialog'; import { RollbackDialog } from './RollbackDialog';
import { useEntityVersions } from '@/hooks/useEntityVersions'; import { useEntityVersions } from '@/hooks/useEntityVersions';
import { useUserRole } from '@/hooks/useUserRole';
import type { EntityType } from '@/types/versioning'; import type { EntityType } from '@/types/versioning';
interface EntityVersionHistoryProps { interface EntityVersionHistoryProps {
@@ -28,6 +29,7 @@ const changeTypeColors = {
export function EntityVersionHistory({ entityType, entityId, entityName }: EntityVersionHistoryProps) { export function EntityVersionHistory({ entityType, entityId, entityName }: EntityVersionHistoryProps) {
const { versions, loading, rollbackToVersion } = useEntityVersions(entityType, entityId); const { versions, loading, rollbackToVersion } = useEntityVersions(entityType, entityId);
const { isModerator } = useUserRole();
const [selectedVersions, setSelectedVersions] = useState<string[]>([]); const [selectedVersions, setSelectedVersions] = useState<string[]>([]);
const [compareDialogOpen, setCompareDialogOpen] = useState(false); const [compareDialogOpen, setCompareDialogOpen] = useState(false);
const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false); const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false);
@@ -128,7 +130,7 @@ export function EntityVersionHistory({ entityType, entityId, entityName }: Entit
)} )}
</div> </div>
{!version.is_current && ( {!version.is_current && isModerator() && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -136,6 +138,7 @@ export function EntityVersionHistory({ entityType, entityId, entityName }: Entit
e.stopPropagation(); e.stopPropagation();
handleRollback(version.version_id); handleRollback(version.version_id);
}} }}
title="Moderator only: Restore this version"
> >
<RotateCcw className="h-4 w-4 mr-1" /> <RotateCcw className="h-4 w-4 mr-1" />
Restore Restore

View File

@@ -43,9 +43,9 @@ export function RollbackDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Restore Previous Version</DialogTitle> <DialogTitle>Restore Previous Version (Moderator Action)</DialogTitle>
<DialogDescription> <DialogDescription>
You are about to restore "{entityName}" to a previous version. This will create a new version with the restored data. You are about to restore "{entityName}" to a previous version. This will create a new version with the restored data. This action will be logged in the audit trail.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -164,10 +164,16 @@ export function useEntityVersions(entityType: EntityType, entityId: string) {
p_reason: reason p_reason: reason
}); });
if (error) throw error; if (error) {
// Check for authorization error (insufficient_privilege)
if (error.code === '42501') {
throw new Error('Only moderators can restore previous versions');
}
throw error;
}
if (isMountedRef.current) { if (isMountedRef.current) {
toast.success('Successfully rolled back to previous version'); toast.success('Successfully restored to previous version');
await fetchVersions(); await fetchVersions();
} }
return data; return data;
@@ -175,7 +181,9 @@ export function useEntityVersions(entityType: EntityType, entityId: string) {
const errorMsg = getErrorMessage(error); const errorMsg = getErrorMessage(error);
logger.error('Failed to rollback version', { entityType, entityId, targetVersionId, error: errorMsg }); logger.error('Failed to rollback version', { entityType, entityId, targetVersionId, error: errorMsg });
if (isMountedRef.current) { if (isMountedRef.current) {
toast.error(errorMsg); toast.error('Failed to restore version', {
description: errorMsg
});
} }
return null; return null;
} }

View File

@@ -0,0 +1,205 @@
-- Drop the broken JSONB-based rollback function
DROP FUNCTION IF EXISTS public.rollback_to_version(TEXT, UUID, UUID, UUID, TEXT);
-- Create new relational rollback function with moderator-only access
CREATE OR REPLACE FUNCTION public.rollback_to_version(
p_entity_type TEXT,
p_entity_id UUID,
p_target_version_id UUID,
p_changed_by UUID,
p_reason TEXT
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_version_table TEXT;
v_entity_table TEXT;
v_entity_id_col TEXT;
v_new_version_id UUID;
v_version_data RECORD;
BEGIN
-- CRITICAL: Check authorization FIRST
IF NOT is_moderator(p_changed_by) THEN
RAISE EXCEPTION 'Only moderators can rollback versions'
USING ERRCODE = '42501'; -- insufficient_privilege
END IF;
-- Validate entity type
IF p_entity_type NOT IN ('park', 'ride', 'company', 'ride_model') THEN
RAISE EXCEPTION 'Invalid entity type: %', p_entity_type;
END IF;
-- Determine table names
v_version_table := p_entity_type || '_versions';
v_entity_id_col := p_entity_type || '_id';
v_entity_table := CASE p_entity_type
WHEN 'park' THEN 'parks'
WHEN 'ride' THEN 'rides'
WHEN 'company' THEN 'companies'
WHEN 'ride_model' THEN 'ride_models'
END;
-- Get the target version data
EXECUTE format('SELECT * FROM %I WHERE version_id = $1', v_version_table)
INTO v_version_data
USING p_target_version_id;
IF v_version_data IS NULL THEN
RAISE EXCEPTION 'Version not found: %', p_target_version_id;
END IF;
-- Set session variables for trigger
PERFORM set_config('app.current_user_id', p_changed_by::text, true);
PERFORM set_config('app.submission_id', '', true);
-- Restore entity data based on type
IF p_entity_type = 'park' THEN
UPDATE parks SET
name = v_version_data.name,
slug = v_version_data.slug,
description = v_version_data.description,
park_type = v_version_data.park_type,
status = v_version_data.status,
location_id = v_version_data.location_id,
operator_id = v_version_data.operator_id,
property_owner_id = v_version_data.property_owner_id,
opening_date = v_version_data.opening_date,
closing_date = v_version_data.closing_date,
opening_date_precision = v_version_data.opening_date_precision,
closing_date_precision = v_version_data.closing_date_precision,
website_url = v_version_data.website_url,
phone = v_version_data.phone,
email = v_version_data.email,
banner_image_url = v_version_data.banner_image_url,
banner_image_id = v_version_data.banner_image_id,
card_image_url = v_version_data.card_image_url,
card_image_id = v_version_data.card_image_id,
updated_at = NOW()
WHERE id = p_entity_id;
ELSIF p_entity_type = 'ride' THEN
UPDATE rides SET
name = v_version_data.name,
slug = v_version_data.slug,
description = v_version_data.description,
category = v_version_data.category,
status = v_version_data.status,
park_id = v_version_data.park_id,
manufacturer_id = v_version_data.manufacturer_id,
designer_id = v_version_data.designer_id,
ride_model_id = v_version_data.ride_model_id,
opening_date = v_version_data.opening_date,
closing_date = v_version_data.closing_date,
opening_date_precision = v_version_data.opening_date_precision,
closing_date_precision = v_version_data.closing_date_precision,
height_requirement = v_version_data.height_requirement_cm,
age_requirement = v_version_data.age_requirement,
max_speed_kmh = v_version_data.max_speed_kmh,
duration_seconds = v_version_data.duration_seconds,
capacity_per_hour = v_version_data.capacity_per_hour,
max_g_force = v_version_data.gforce_max,
inversions = v_version_data.inversions_count,
length_meters = v_version_data.length_meters,
max_height_meters = v_version_data.height_meters,
drop_height_meters = v_version_data.drop_meters,
banner_image_url = v_version_data.banner_image_url,
banner_image_id = v_version_data.banner_image_id,
card_image_url = v_version_data.card_image_url,
card_image_id = v_version_data.card_image_id,
image_url = v_version_data.image_url,
ride_sub_type = v_version_data.ride_sub_type,
coaster_type = v_version_data.coaster_type,
seating_type = v_version_data.seating_type,
intensity_level = v_version_data.intensity_level,
track_material = v_version_data.track_material,
support_material = v_version_data.support_material,
propulsion_method = v_version_data.propulsion_method,
water_depth_cm = v_version_data.water_depth_cm,
splash_height_meters = v_version_data.splash_height_meters,
wetness_level = v_version_data.wetness_level,
flume_type = v_version_data.flume_type,
boat_capacity = v_version_data.boat_capacity,
theme_name = v_version_data.theme_name,
story_description = v_version_data.story_description,
show_duration_seconds = v_version_data.show_duration_seconds,
animatronics_count = v_version_data.animatronics_count,
projection_type = v_version_data.projection_type,
ride_system = v_version_data.ride_system,
scenes_count = v_version_data.scenes_count,
rotation_type = v_version_data.rotation_type,
motion_pattern = v_version_data.motion_pattern,
platform_count = v_version_data.platform_count,
swing_angle_degrees = v_version_data.swing_angle_degrees,
rotation_speed_rpm = v_version_data.rotation_speed_rpm,
arm_length_meters = v_version_data.arm_length_meters,
max_height_reached_meters = v_version_data.max_height_reached_meters,
min_age = v_version_data.min_age,
max_age = v_version_data.max_age,
educational_theme = v_version_data.educational_theme,
character_theme = v_version_data.character_theme,
transport_type = v_version_data.transport_type,
route_length_meters = v_version_data.route_length_meters,
stations_count = v_version_data.stations_count,
vehicle_capacity = v_version_data.vehicle_capacity,
vehicles_count = v_version_data.vehicles_count,
round_trip_duration_seconds = v_version_data.round_trip_duration_seconds,
updated_at = NOW()
WHERE id = p_entity_id;
ELSIF p_entity_type = 'company' THEN
UPDATE companies SET
name = v_version_data.name,
slug = v_version_data.slug,
description = v_version_data.description,
company_type = v_version_data.company_type,
person_type = v_version_data.person_type,
founded_year = v_version_data.founded_year,
founded_date = v_version_data.founded_date,
founded_date_precision = v_version_data.founded_date_precision,
headquarters_location = v_version_data.headquarters_location,
website_url = v_version_data.website_url,
logo_url = v_version_data.logo_url,
banner_image_url = v_version_data.banner_image_url,
banner_image_id = v_version_data.banner_image_id,
card_image_url = v_version_data.card_image_url,
card_image_id = v_version_data.card_image_id,
updated_at = NOW()
WHERE id = p_entity_id;
ELSIF p_entity_type = 'ride_model' THEN
UPDATE ride_models SET
name = v_version_data.name,
slug = v_version_data.slug,
manufacturer_id = v_version_data.manufacturer_id,
category = v_version_data.category,
ride_type = v_version_data.ride_type,
description = v_version_data.description,
banner_image_url = v_version_data.banner_image_url,
banner_image_id = v_version_data.banner_image_id,
card_image_url = v_version_data.card_image_url,
card_image_id = v_version_data.card_image_id,
updated_at = NOW()
WHERE id = p_entity_id;
END IF;
-- Update the newly created version with restored change type and reason
EXECUTE format('
UPDATE %I
SET
change_type = $1,
change_reason = $2
WHERE %I = $3
AND is_current = true
RETURNING version_id
', v_version_table, v_entity_id_col)
INTO v_new_version_id
USING 'restored'::version_change_type,
'Restored to version ' || v_version_data.version_number || ': ' || p_reason,
p_entity_id;
RETURN v_new_version_id;
END;
$$;