mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
Implement Phase 2, Part 2
This commit is contained in:
100
src/components/versioning/VersionIndicator.tsx
Normal file
100
src/components/versioning/VersionIndicator.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
207
src/lib/versioningHelpers.ts
Normal file
207
src/lib/versioningHelpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user