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 (
+ <>
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ Version {currentVersion.version_number}
+
+
+ Last edited {timeAgo}
+
+
+
+
+
+ >
+ );
+}
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';