Implement Phase 2, Part 2

This commit is contained in:
gpt-engineer-app[bot]
2025-10-06 15:43:50 +00:00
parent a3928c7f23
commit badf3507de
4 changed files with 557 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { History, Clock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { EntityVersionHistory } from './EntityVersionHistory';
import { useEntityVersions } from '@/hooks/useEntityVersions';
interface VersionIndicatorProps {
entityType: string;
entityId: string;
entityName: string;
compact?: boolean;
}
export function VersionIndicator({
entityType,
entityId,
entityName,
compact = false,
}: VersionIndicatorProps) {
const [showHistory, setShowHistory] = useState(false);
const { currentVersion, loading } = useEntityVersions(entityType, entityId);
if (loading || !currentVersion) {
return null;
}
const timeAgo = currentVersion.changed_at
? formatDistanceToNow(new Date(currentVersion.changed_at), { addSuffix: true })
: 'Unknown';
if (compact) {
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
className="gap-2"
>
<History className="h-4 w-4" />
<span className="text-xs text-muted-foreground">
v{currentVersion.version_number}
</span>
</Button>
<Dialog open={showHistory} onOpenChange={setShowHistory}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Version History: {entityName}</DialogTitle>
</DialogHeader>
<EntityVersionHistory
entityType={entityType}
entityId={entityId}
entityName={entityName}
/>
</DialogContent>
</Dialog>
</>
);
}
return (
<>
<div className="flex items-center gap-3">
<Badge variant="outline" className="gap-1.5">
<Clock className="h-3 w-3" />
Version {currentVersion.version_number}
</Badge>
<span className="text-sm text-muted-foreground">
Last edited {timeAgo}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowHistory(true)}
className="gap-2"
>
<History className="h-4 w-4" />
View History
</Button>
</div>
<Dialog open={showHistory} onOpenChange={setShowHistory}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Version History: {entityName}</DialogTitle>
</DialogHeader>
<EntityVersionHistory
entityType={entityType}
entityId={entityId}
entityName={entityName}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -166,8 +166,17 @@ export async function approveSubmissionItems(
for (const item of sortedItems) {
let entityId: string | null = null;
let isEdit = false;
try {
// Determine if this is an edit by checking for entity_id in item_data
isEdit = !!(
item.item_data.park_id ||
item.item_data.ride_id ||
item.item_data.company_id ||
item.item_data.ride_model_id
);
// Create the entity based on type with dependency resolution
switch (item.item_type) {
case 'park':
@@ -200,6 +209,17 @@ export async function approveSubmissionItems(
approved_entity_id: entityId,
});
// Create version history (skip for photo type)
if (item.item_type !== 'photo') {
await createVersionForApprovedItem(
item.item_type,
entityId,
userId,
item.submission_id,
isEdit
);
}
// Add to dependency map for child items
dependencyMap.set(item.id, entityId);
@@ -217,6 +237,62 @@ export async function approveSubmissionItems(
}
}
/**
* Create version history for approved submission item
*/
async function createVersionForApprovedItem(
itemType: string,
entityId: string,
userId: string,
submissionId: string,
isEdit: boolean
): Promise<void> {
const { captureCurrentState, createEntityVersion } = await import('./versioningHelpers');
// Map item_type to entity_type
let entityType: 'park' | 'ride' | 'company' | 'ride_model';
switch (itemType) {
case 'park':
entityType = 'park';
break;
case 'ride':
entityType = 'ride';
break;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
entityType = 'company';
break;
case 'ride_model':
entityType = 'ride_model';
break;
default:
console.warn(`Unknown entity type for versioning: ${itemType}`);
return;
}
// Capture current state
const currentState = await captureCurrentState(entityType, entityId);
if (!currentState) {
console.warn(`Failed to capture state for ${entityType} ${entityId}`);
return;
}
// Create version
await createEntityVersion({
entityType,
entityId,
versionData: currentState,
changedBy: userId,
changeReason: isEdit
? `Approved edit from submission #${submissionId.slice(0, 8)}`
: `Created via submission #${submissionId.slice(0, 8)}`,
submissionId,
changeType: isEdit ? 'updated' : 'created',
});
}
/**
* Topological sort for dependency-ordered processing
*/

View File

@@ -0,0 +1,207 @@
import { supabase } from '@/integrations/supabase/client';
import { toast } from '@/hooks/use-toast';
export type EntityType = 'park' | 'ride' | 'company' | 'ride_model';
export type ChangeType = 'created' | 'updated' | 'deleted' | 'restored' | 'archived';
/**
* Get the table name for a given entity type
*/
export function getEntityTableName(entityType: EntityType): string {
const tableMap: Record<EntityType, string> = {
park: 'parks',
ride: 'rides',
company: 'companies',
ride_model: 'ride_models',
};
return tableMap[entityType];
}
/**
* Capture the current state of an entity
*/
export async function captureCurrentState(
entityType: EntityType,
entityId: string
): Promise<Record<string, any> | null> {
const tableName = getEntityTableName(entityType);
const { data, error } = await supabase
.from(tableName as any)
.select('*')
.eq('id', entityId)
.single();
if (error) {
console.error('Error capturing entity state:', error);
return null;
}
return data as Record<string, any>;
}
/**
* Create a new entity version with proper error handling
*/
export async function createEntityVersion(params: {
entityType: EntityType;
entityId: string;
versionData: Record<string, any>;
changedBy: string;
changeReason?: string;
submissionId?: string;
changeType?: ChangeType;
}): Promise<string | null> {
const {
entityType,
entityId,
versionData,
changedBy,
changeReason,
submissionId,
changeType = 'updated',
} = params;
try {
const { data, error } = await supabase.rpc('create_entity_version', {
p_entity_type: entityType,
p_entity_id: entityId,
p_version_data: versionData,
p_changed_by: changedBy,
p_change_reason: changeReason || null,
p_submission_id: submissionId || null,
p_change_type: changeType,
});
if (error) {
console.error('Error creating entity version:', error);
toast({
title: 'Version Creation Failed',
description: error.message,
variant: 'destructive',
});
return null;
}
return data;
} catch (err) {
console.error('Unexpected error creating entity version:', err);
toast({
title: 'Version Creation Failed',
description: 'An unexpected error occurred',
variant: 'destructive',
});
return null;
}
}
/**
* Create entity version with audit log entry
*/
export async function createEntityVersionWithAudit(
params: {
entityType: EntityType;
entityId: string;
versionData: Record<string, any>;
changedBy: string;
changeReason?: string;
submissionId?: string;
changeType?: ChangeType;
},
auditDetails?: Record<string, any>
): Promise<string | null> {
const versionId = await createEntityVersion(params);
if (versionId && auditDetails) {
// Log to admin audit log
const { error: auditError } = await supabase.rpc('log_admin_action', {
_admin_user_id: params.changedBy,
_target_user_id: params.changedBy, // Or entity owner if available
_action: `version_${params.changeType || 'updated'}`,
_details: {
version_id: versionId,
entity_type: params.entityType,
entity_id: params.entityId,
submission_id: params.submissionId,
...auditDetails,
},
});
if (auditError) {
console.warn('Failed to create audit log entry:', auditError);
}
}
return versionId;
}
/**
* Rollback an entity to a previous version
*/
export async function rollbackToVersion(
entityType: EntityType,
entityId: string,
targetVersionId: string,
userId: string,
reason: string
): Promise<boolean> {
try {
const { data, error } = await supabase.rpc('rollback_to_version', {
p_entity_type: entityType,
p_entity_id: entityId,
p_target_version_id: targetVersionId,
p_changed_by: userId,
p_reason: reason,
});
if (error) {
console.error('Error rolling back to version:', error);
toast({
title: 'Rollback Failed',
description: error.message,
variant: 'destructive',
});
return false;
}
toast({
title: 'Rollback Successful',
description: 'Entity has been restored to the selected version',
});
return true;
} catch (err) {
console.error('Unexpected error during rollback:', err);
toast({
title: 'Rollback Failed',
description: 'An unexpected error occurred',
variant: 'destructive',
});
return false;
}
}
/**
* Compare two versions and get the diff
*/
export async function compareVersions(
fromVersionId: string,
toVersionId: string
): Promise<Record<string, any> | null> {
try {
const { data, error } = await supabase.rpc('compare_versions', {
p_from_version_id: fromVersionId,
p_to_version_id: toVersionId,
});
if (error) {
console.error('Error comparing versions:', error);
return null;
}
return data as Record<string, any>;
} catch (err) {
console.error('Unexpected error comparing versions:', err);
return null;
}
}

View File

@@ -0,0 +1,174 @@
-- Phase 2: Fix rollback function and add auto-versioning triggers
-- Drop the placeholder rollback function
DROP FUNCTION IF EXISTS public.rollback_to_version(text, uuid, uuid, uuid, text);
-- Enhanced rollback function with dynamic SQL
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_target_data JSONB;
v_table_name TEXT;
v_new_version_id UUID;
v_key TEXT;
v_value TEXT;
v_update_parts TEXT[];
v_sql TEXT;
BEGIN
-- Get target version data
SELECT version_data INTO v_target_data
FROM public.entity_versions
WHERE id = p_target_version_id
AND entity_type = p_entity_type
AND entity_id = p_entity_id;
IF v_target_data IS NULL THEN
RAISE EXCEPTION 'Target version not found';
END IF;
-- Determine table name from entity type
v_table_name := CASE p_entity_type
WHEN 'park' THEN 'parks'
WHEN 'ride' THEN 'rides'
WHEN 'company' THEN 'companies'
WHEN 'ride_model' THEN 'ride_models'
ELSE p_entity_type || 's'
END;
-- Build UPDATE statement dynamically
v_update_parts := ARRAY[]::TEXT[];
FOR v_key, v_value IN SELECT * FROM jsonb_each_text(v_target_data)
LOOP
-- Skip metadata fields
IF v_key NOT IN ('id', 'created_at', 'updated_at') THEN
v_update_parts := array_append(v_update_parts,
format('%I = %L', v_key, v_value)
);
END IF;
END LOOP;
-- Execute the UPDATE
IF array_length(v_update_parts, 1) > 0 THEN
v_sql := format(
'UPDATE %I SET %s, updated_at = now() WHERE id = %L',
v_table_name,
array_to_string(v_update_parts, ', '),
p_entity_id
);
EXECUTE v_sql;
END IF;
-- Create new version with restored change type
v_new_version_id := public.create_entity_version(
p_entity_type,
p_entity_id,
v_target_data,
p_changed_by,
'Rolled back: ' || p_reason,
NULL,
'restored'
);
RETURN v_new_version_id;
END;
$$;
-- Auto-version creation trigger function
CREATE OR REPLACE FUNCTION public.auto_create_entity_version()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_change_type version_change_type;
v_user_id UUID;
v_version_data JSONB;
BEGIN
-- Determine entity type from table name
v_entity_type := CASE TG_TABLE_NAME
WHEN 'parks' THEN 'park'
WHEN 'rides' THEN 'ride'
WHEN 'companies' THEN 'company'
WHEN 'ride_models' THEN 'ride_model'
ELSE substring(TG_TABLE_NAME from 1 for length(TG_TABLE_NAME) - 1)
END;
-- Determine change type
v_change_type := CASE TG_OP
WHEN 'INSERT' THEN 'created'::version_change_type
WHEN 'UPDATE' THEN 'updated'::version_change_type
ELSE 'updated'::version_change_type
END;
-- Get user from session or auth context
BEGIN
v_user_id := current_setting('app.current_user_id', true)::UUID;
EXCEPTION WHEN OTHERS THEN
v_user_id := auth.uid();
END;
-- Convert NEW record to JSONB
v_version_data := to_jsonb(NEW);
-- Create version (only if we have a user context)
IF v_user_id IS NOT NULL THEN
PERFORM public.create_entity_version(
v_entity_type,
NEW.id,
v_version_data,
v_user_id,
CASE TG_OP
WHEN 'INSERT' THEN 'Entity created'
WHEN 'UPDATE' THEN 'Entity updated'
ELSE 'Entity modified'
END,
NULL,
v_change_type
);
END IF;
RETURN NEW;
END;
$$;
-- Add triggers to entity tables
DROP TRIGGER IF EXISTS auto_version_parks ON public.parks;
CREATE TRIGGER auto_version_parks
AFTER INSERT OR UPDATE ON public.parks
FOR EACH ROW
EXECUTE FUNCTION public.auto_create_entity_version();
DROP TRIGGER IF EXISTS auto_version_rides ON public.rides;
CREATE TRIGGER auto_version_rides
AFTER INSERT OR UPDATE ON public.rides
FOR EACH ROW
EXECUTE FUNCTION public.auto_create_entity_version();
DROP TRIGGER IF EXISTS auto_version_companies ON public.companies;
CREATE TRIGGER auto_version_companies
AFTER INSERT OR UPDATE ON public.companies
FOR EACH ROW
EXECUTE FUNCTION public.auto_create_entity_version();
DROP TRIGGER IF EXISTS auto_version_ride_models ON public.ride_models;
CREATE TRIGGER auto_version_ride_models
AFTER INSERT OR UPDATE ON public.ride_models
FOR EACH ROW
EXECUTE FUNCTION public.auto_create_entity_version();
COMMENT ON FUNCTION public.rollback_to_version IS 'Restores an entity to a previous version using dynamic SQL';
COMMENT ON FUNCTION public.auto_create_entity_version IS 'Automatically creates version records when entities are modified';