diff --git a/src/components/versioning/VersionIndicator.tsx b/src/components/versioning/VersionIndicator.tsx new file mode 100644 index 00000000..95b16dc8 --- /dev/null +++ b/src/components/versioning/VersionIndicator.tsx @@ -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 ( + <> + + + + + + Version History: {entityName} + + + + + + ); + } + + return ( + <> +
+ + + Version {currentVersion.version_number} + + + Last edited {timeAgo} + + +
+ + + + + Version History: {entityName} + + + + + + ); +} diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index b9ac6a8d..5435cb3f 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -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 { + 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 */ diff --git a/src/lib/versioningHelpers.ts b/src/lib/versioningHelpers.ts new file mode 100644 index 00000000..a01a47cc --- /dev/null +++ b/src/lib/versioningHelpers.ts @@ -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 = { + 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 | 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; +} + +/** + * Create a new entity version with proper error handling + */ +export async function createEntityVersion(params: { + entityType: EntityType; + entityId: string; + versionData: Record; + changedBy: string; + changeReason?: string; + submissionId?: string; + changeType?: ChangeType; +}): Promise { + 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; + changedBy: string; + changeReason?: string; + submissionId?: string; + changeType?: ChangeType; + }, + auditDetails?: Record +): Promise { + 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 { + 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 | 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; + } catch (err) { + console.error('Unexpected error comparing versions:', err); + return null; + } +} diff --git a/supabase/migrations/20251006154129_c511f797-c8c5-4e87-82e8-6a139739beab.sql b/supabase/migrations/20251006154129_c511f797-c8c5-4e87-82e8-6a139739beab.sql new file mode 100644 index 00000000..20bdb1d3 --- /dev/null +++ b/supabase/migrations/20251006154129_c511f797-c8c5-4e87-82e8-6a139739beab.sql @@ -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';