mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
Add versioning system tables
This commit is contained in:
212
src/components/versioning/EntityVersionHistory.tsx
Normal file
212
src/components/versioning/EntityVersionHistory.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Clock, GitBranch, User, FileText, RotateCcw, GitCompare } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { VersionComparisonDialog } from './VersionComparisonDialog';
|
||||||
|
import { RollbackDialog } from './RollbackDialog';
|
||||||
|
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||||
|
|
||||||
|
interface EntityVersionHistoryProps {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
entityName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeTypeColors = {
|
||||||
|
created: 'bg-green-500/10 text-green-700 dark:text-green-400',
|
||||||
|
updated: 'bg-blue-500/10 text-blue-700 dark:text-blue-400',
|
||||||
|
deleted: 'bg-red-500/10 text-red-700 dark:text-red-400',
|
||||||
|
restored: 'bg-purple-500/10 text-purple-700 dark:text-purple-400',
|
||||||
|
archived: 'bg-gray-500/10 text-gray-700 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntityVersionHistory({ entityType, entityId, entityName }: EntityVersionHistoryProps) {
|
||||||
|
const { versions, loading, rollbackToVersion } = useEntityVersions(entityType, entityId);
|
||||||
|
const [selectedVersions, setSelectedVersions] = useState<string[]>([]);
|
||||||
|
const [compareDialogOpen, setCompareDialogOpen] = useState(false);
|
||||||
|
const [rollbackDialogOpen, setRollbackDialogOpen] = useState(false);
|
||||||
|
const [selectedVersionForRollback, setSelectedVersionForRollback] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleVersionSelect = (versionId: string) => {
|
||||||
|
setSelectedVersions(prev => {
|
||||||
|
if (prev.includes(versionId)) {
|
||||||
|
return prev.filter(id => id !== versionId);
|
||||||
|
}
|
||||||
|
if (prev.length >= 2) {
|
||||||
|
return [prev[1], versionId];
|
||||||
|
}
|
||||||
|
return [...prev, versionId];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompare = () => {
|
||||||
|
if (selectedVersions.length === 2) {
|
||||||
|
setCompareDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollback = (versionId: string) => {
|
||||||
|
setSelectedVersionForRollback(versionId);
|
||||||
|
setRollbackDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No version history available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Version History</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{versions.length} versions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVersions.length === 2 && (
|
||||||
|
<Button onClick={handleCompare} size="sm">
|
||||||
|
<GitCompare className="h-4 w-4 mr-2" />
|
||||||
|
Compare Selected
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<ScrollArea className="h-[600px] pr-4">
|
||||||
|
<div className="relative space-y-4">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-6 top-0 bottom-0 w-px bg-border" />
|
||||||
|
|
||||||
|
{versions.map((version, index) => (
|
||||||
|
<Card
|
||||||
|
key={version.id}
|
||||||
|
className={`relative pl-16 pr-4 py-4 cursor-pointer transition-colors ${
|
||||||
|
selectedVersions.includes(version.id)
|
||||||
|
? 'border-primary bg-accent'
|
||||||
|
: 'hover:border-primary/50'
|
||||||
|
} ${version.is_current ? 'border-primary shadow-md' : ''}`}
|
||||||
|
onClick={() => handleVersionSelect(version.id)}
|
||||||
|
>
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-4 top-4 w-5 h-5 rounded-full border-4 border-background ${
|
||||||
|
version.is_current
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Badge variant="outline" className={changeTypeColors[version.change_type]}>
|
||||||
|
{version.change_type}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">Version {version.version_number}</span>
|
||||||
|
{version.is_current && (
|
||||||
|
<Badge variant="default">Current</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!version.is_current && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRollback(version.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1" />
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={version.changer_profile?.avatar_url || undefined} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{version.changer_profile?.username || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{formatDistanceToNow(new Date(version.changed_at), { addSuffix: true })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{version.submission_id && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
<span>Submission</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Reason */}
|
||||||
|
{version.change_reason && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-sm italic">{version.change_reason}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
{selectedVersions.length === 2 && (
|
||||||
|
<VersionComparisonDialog
|
||||||
|
open={compareDialogOpen}
|
||||||
|
onOpenChange={setCompareDialogOpen}
|
||||||
|
entityType={entityType}
|
||||||
|
entityId={entityId}
|
||||||
|
fromVersionId={selectedVersions[0]}
|
||||||
|
toVersionId={selectedVersions[1]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedVersionForRollback && (
|
||||||
|
<RollbackDialog
|
||||||
|
open={rollbackDialogOpen}
|
||||||
|
onOpenChange={setRollbackDialogOpen}
|
||||||
|
versionId={selectedVersionForRollback}
|
||||||
|
entityType={entityType}
|
||||||
|
entityId={entityId}
|
||||||
|
entityName={entityName}
|
||||||
|
onRollback={async (reason) => {
|
||||||
|
await rollbackToVersion(selectedVersionForRollback, reason);
|
||||||
|
setRollbackDialogOpen(false);
|
||||||
|
setSelectedVersionForRollback(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/components/versioning/FieldHistoryDialog.tsx
Normal file
216
src/components/versioning/FieldHistoryDialog.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Clock, Plus, Minus, Edit } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
|
||||||
|
interface FieldHistoryDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
fieldName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldChange {
|
||||||
|
id: string;
|
||||||
|
field_name: string;
|
||||||
|
old_value: any;
|
||||||
|
new_value: any;
|
||||||
|
change_type: 'added' | 'modified' | 'removed';
|
||||||
|
created_at: string;
|
||||||
|
version: {
|
||||||
|
version_number: number;
|
||||||
|
changed_by: string;
|
||||||
|
changed_at: string;
|
||||||
|
changer_profile: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldHistoryDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
fieldName,
|
||||||
|
}: FieldHistoryDialogProps) {
|
||||||
|
const [changes, setChanges] = useState<FieldChange[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFieldHistory = async () => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Get all versions for this entity
|
||||||
|
const { data: versions, error: versionsError } = await supabase
|
||||||
|
.from('entity_versions')
|
||||||
|
.select('id, version_number, changed_by, changed_at')
|
||||||
|
.eq('entity_type', entityType)
|
||||||
|
.eq('entity_id', entityId)
|
||||||
|
.order('version_number', { ascending: false });
|
||||||
|
|
||||||
|
if (versionsError) throw versionsError;
|
||||||
|
|
||||||
|
// Get profiles
|
||||||
|
const userIds = [...new Set(versions?.map(v => v.changed_by).filter(Boolean) || [])];
|
||||||
|
const { data: profiles } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('user_id, username')
|
||||||
|
.in('user_id', userIds);
|
||||||
|
|
||||||
|
// Get field history for all these versions
|
||||||
|
const versionIds = versions?.map(v => v.id) || [];
|
||||||
|
|
||||||
|
const { data: fieldHistory, error: historyError } = await supabase
|
||||||
|
.from('entity_field_history')
|
||||||
|
.select('*')
|
||||||
|
.eq('field_name', fieldName)
|
||||||
|
.in('version_id', versionIds)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (historyError) throw historyError;
|
||||||
|
|
||||||
|
// Merge the data
|
||||||
|
const mergedData = fieldHistory?.map(change => ({
|
||||||
|
...change,
|
||||||
|
version: {
|
||||||
|
...versions?.find(v => v.id === change.version_id),
|
||||||
|
changer_profile: profiles?.find(p => p.user_id === versions?.find(v => v.id === change.version_id)?.changed_by)
|
||||||
|
},
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
setChanges(mergedData as FieldChange[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching field history:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFieldHistory();
|
||||||
|
}, [open, entityType, entityId, fieldName]);
|
||||||
|
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return 'null';
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.split('_')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'added':
|
||||||
|
return <Plus className="h-4 w-4 text-green-500" />;
|
||||||
|
case 'removed':
|
||||||
|
return <Minus className="h-4 w-4 text-red-500" />;
|
||||||
|
case 'modified':
|
||||||
|
return <Edit className="h-4 w-4 text-blue-500" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangeBadgeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'added':
|
||||||
|
return 'bg-green-500/10 text-green-700 dark:text-green-400';
|
||||||
|
case 'removed':
|
||||||
|
return 'bg-red-500/10 text-red-700 dark:text-red-400';
|
||||||
|
case 'modified':
|
||||||
|
return 'bg-blue-500/10 text-blue-700 dark:text-blue-400';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Field History: {formatFieldName(fieldName)}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[500px] pr-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : changes.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{changes.map((change) => (
|
||||||
|
<div key={change.id} className="border rounded-lg p-4 space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getChangeIcon(change.change_type)}
|
||||||
|
<Badge variant="outline" className={getChangeBadgeColor(change.change_type)}>
|
||||||
|
{change.change_type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Version {change.version?.version_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{change.version?.changer_profile?.username}</span>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(new Date(change.created_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Values */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{change.old_value !== null && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground font-medium">Previous Value</div>
|
||||||
|
<div className="p-3 rounded-md bg-muted font-mono text-xs whitespace-pre-wrap">
|
||||||
|
{formatValue(change.old_value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.new_value !== null && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground font-medium">New Value</div>
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||||
|
change.change_type === 'added'
|
||||||
|
? 'bg-green-500/10 text-green-700 dark:text-green-400 font-semibold'
|
||||||
|
: change.change_type === 'modified'
|
||||||
|
? 'bg-blue-500/10 text-blue-700 dark:text-blue-400 font-semibold'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatValue(change.new_value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p>No history found for this field</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/versioning/HistoricalEntityCard.tsx
Normal file
113
src/components/versioning/HistoricalEntityCard.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar, MapPin, ArrowRight, Building2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface HistoricalEntityCardProps {
|
||||||
|
type: 'park' | 'ride';
|
||||||
|
entity: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
operated_from?: string;
|
||||||
|
operated_until?: string;
|
||||||
|
closure_reason?: string;
|
||||||
|
removal_reason?: string;
|
||||||
|
location?: {
|
||||||
|
name: string;
|
||||||
|
city?: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
successor?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
relocated_to?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
onViewDetails?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoricalEntityCard({ type, entity, onViewDetails }: HistoricalEntityCardProps) {
|
||||||
|
const reason = type === 'park' ? entity.closure_reason : entity.removal_reason;
|
||||||
|
const hasSuccessor = type === 'park' ? !!entity.successor : !!entity.relocated_to;
|
||||||
|
const successorInfo = type === 'park' ? entity.successor : entity.relocated_to;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{entity.name}
|
||||||
|
<Badge variant="secondary" className="bg-gray-500/10">
|
||||||
|
Historical
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1 mt-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{entity.location?.city && `${entity.location.city}, `}
|
||||||
|
{entity.location?.country || entity.location?.name}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Operating Dates */}
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Operated:</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{entity.operated_from && format(new Date(entity.operated_from), 'MMM d, yyyy')}
|
||||||
|
{' - '}
|
||||||
|
{entity.operated_until && format(new Date(entity.operated_until), 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Closure/Removal Reason */}
|
||||||
|
{reason && (
|
||||||
|
<div className="p-3 rounded-md bg-muted">
|
||||||
|
<div className="text-xs text-muted-foreground font-medium mb-1">
|
||||||
|
{type === 'park' ? 'Closure Reason' : 'Removal Reason'}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{reason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Successor/Relocation Info */}
|
||||||
|
{hasSuccessor && successorInfo && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-md bg-primary/5 border border-primary/20">
|
||||||
|
<Building2 className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{type === 'park' ? 'Succeeded by' : 'Relocated to'}:
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 text-primary"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `/${type}s/${successorInfo.slug}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{successorInfo.name}
|
||||||
|
<ArrowRight className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View Details Button */}
|
||||||
|
{onViewDetails && (
|
||||||
|
<Button variant="outline" className="w-full" onClick={onViewDetails}>
|
||||||
|
View Full History
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/versioning/RollbackDialog.tsx
Normal file
92
src/components/versioning/RollbackDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
|
||||||
|
interface RollbackDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
versionId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
entityName: string;
|
||||||
|
onRollback: (reason: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RollbackDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
versionId,
|
||||||
|
entityName,
|
||||||
|
onRollback,
|
||||||
|
}: RollbackDialogProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRollback = async () => {
|
||||||
|
if (!reason.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onRollback(reason);
|
||||||
|
setReason('');
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Restore Previous Version</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You are about to restore "{entityName}" to a previous version. This will create a new version with the restored data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="default" className="border-yellow-500/50 bg-yellow-500/10">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500" />
|
||||||
|
<AlertDescription className="text-yellow-900 dark:text-yellow-200">
|
||||||
|
This action will restore the entity to its previous state. The current version will be preserved in history.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rollback-reason">Reason for rollback *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="rollback-reason"
|
||||||
|
placeholder="Explain why you're rolling back to this version..."
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This reason will be logged in the version history for audit purposes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRollback}
|
||||||
|
disabled={!reason.trim() || loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Restoring...' : 'Restore Version'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/components/versioning/VersionComparisonDialog.tsx
Normal file
157
src/components/versioning/VersionComparisonDialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { ArrowRight, Plus, Minus, Edit } from 'lucide-react';
|
||||||
|
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||||
|
|
||||||
|
interface VersionComparisonDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
fromVersionId: string;
|
||||||
|
toVersionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionComparisonDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
fromVersionId,
|
||||||
|
toVersionId,
|
||||||
|
}: VersionComparisonDialogProps) {
|
||||||
|
const { versions, compareVersions } = useEntityVersions(entityType, entityId);
|
||||||
|
const [diff, setDiff] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fromVersion = versions.find(v => v.id === fromVersionId);
|
||||||
|
const toVersion = versions.find(v => v.id === toVersionId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDiff = async () => {
|
||||||
|
if (open && fromVersionId && toVersionId) {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await compareVersions(fromVersionId, toVersionId);
|
||||||
|
setDiff(result);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDiff();
|
||||||
|
}, [open, fromVersionId, toVersionId]);
|
||||||
|
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return 'null';
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFieldName = (fieldName: string): string => {
|
||||||
|
return fieldName
|
||||||
|
.split('_')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Compare Versions</DialogTitle>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">Version {fromVersion?.version_number}</Badge>
|
||||||
|
<span>{new Date(fromVersion?.changed_at || '').toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">Version {toVersion?.version_number}</Badge>
|
||||||
|
<span>{new Date(toVersion?.changed_at || '').toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[500px] pr-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : diff && Object.keys(diff).length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(diff).map(([fieldName, changes]: [string, any]) => {
|
||||||
|
const isAdded = changes.from === null;
|
||||||
|
const isRemoved = changes.to === null;
|
||||||
|
const isModified = changes.from !== null && changes.to !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={fieldName} className="border rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isAdded && <Plus className="h-4 w-4 text-green-500" />}
|
||||||
|
{isRemoved && <Minus className="h-4 w-4 text-red-500" />}
|
||||||
|
{isModified && <Edit className="h-4 w-4 text-blue-500" />}
|
||||||
|
<span className="font-medium">{formatFieldName(fieldName)}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
isAdded
|
||||||
|
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||||
|
: isRemoved
|
||||||
|
? 'bg-red-500/10 text-red-700 dark:text-red-400'
|
||||||
|
: 'bg-blue-500/10 text-blue-700 dark:text-blue-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isAdded ? 'Added' : isRemoved ? 'Removed' : 'Modified'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* From value */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground font-medium">Before</div>
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||||
|
isRemoved
|
||||||
|
? 'bg-red-500/10 text-red-700 dark:text-red-400 line-through'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatValue(changes.from)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* To value */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground font-medium">After</div>
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md font-mono text-xs whitespace-pre-wrap ${
|
||||||
|
isAdded
|
||||||
|
? 'bg-green-500/10 text-green-700 dark:text-green-400 font-semibold'
|
||||||
|
: isModified
|
||||||
|
? 'bg-blue-500/10 text-blue-700 dark:text-blue-400 font-semibold'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatValue(changes.to)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p>No differences found between these versions</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src/hooks/useEntityVersions.ts
Normal file
199
src/hooks/useEntityVersions.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface EntityVersion {
|
||||||
|
id: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string;
|
||||||
|
version_number: number;
|
||||||
|
version_data: any;
|
||||||
|
changed_by: string;
|
||||||
|
changed_at: string;
|
||||||
|
change_reason: string | null;
|
||||||
|
change_type: 'created' | 'updated' | 'deleted' | 'restored' | 'archived';
|
||||||
|
submission_id: string | null;
|
||||||
|
is_current: boolean;
|
||||||
|
ip_address_hash: string | null;
|
||||||
|
metadata: any;
|
||||||
|
changer_profile?: {
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldChange {
|
||||||
|
id: string;
|
||||||
|
field_name: string;
|
||||||
|
old_value: any;
|
||||||
|
new_value: any;
|
||||||
|
change_type: 'added' | 'modified' | 'removed';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEntityVersions(entityType: string, entityId: string) {
|
||||||
|
const [versions, setVersions] = useState<EntityVersion[]>([]);
|
||||||
|
const [currentVersion, setCurrentVersion] = useState<EntityVersion | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fieldHistory, setFieldHistory] = useState<FieldChange[]>([]);
|
||||||
|
|
||||||
|
const fetchVersions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('entity_versions')
|
||||||
|
.select('*')
|
||||||
|
.eq('entity_type', entityType)
|
||||||
|
.eq('entity_id', entityId)
|
||||||
|
.order('version_number', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Fetch profiles separately
|
||||||
|
const userIds = [...new Set(data?.map(v => v.changed_by).filter(Boolean) || [])];
|
||||||
|
const { data: profiles } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('user_id, username, avatar_url')
|
||||||
|
.in('user_id', userIds);
|
||||||
|
|
||||||
|
const versionsWithProfiles = data?.map(v => ({
|
||||||
|
...v,
|
||||||
|
changer_profile: profiles?.find(p => p.user_id === v.changed_by)
|
||||||
|
})) as EntityVersion[];
|
||||||
|
|
||||||
|
setVersions(versionsWithProfiles || []);
|
||||||
|
setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching versions:', error);
|
||||||
|
toast.error('Failed to load version history');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFieldHistory = async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('entity_field_history')
|
||||||
|
.select('*')
|
||||||
|
.eq('version_id', versionId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
setFieldHistory(data as FieldChange[] || []);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching field history:', error);
|
||||||
|
toast.error('Failed to load field history');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareVersions = async (fromVersionId: string, toVersionId: string) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('compare_versions', {
|
||||||
|
p_from_version_id: fromVersionId,
|
||||||
|
p_to_version_id: toVersionId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error comparing versions:', error);
|
||||||
|
toast.error('Failed to compare versions');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rollbackToVersion = async (targetVersionId: string, reason: string) => {
|
||||||
|
try {
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
if (!userData.user) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
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: userData.user.id,
|
||||||
|
p_reason: reason
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast.success('Successfully rolled back to previous version');
|
||||||
|
await fetchVersions();
|
||||||
|
return data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error rolling back version:', error);
|
||||||
|
toast.error('Failed to rollback version');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createVersion = async (versionData: any, changeReason?: string, submissionId?: string) => {
|
||||||
|
try {
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
if (!userData.user) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc('create_entity_version', {
|
||||||
|
p_entity_type: entityType,
|
||||||
|
p_entity_id: entityId,
|
||||||
|
p_version_data: versionData,
|
||||||
|
p_changed_by: userData.user.id,
|
||||||
|
p_change_reason: changeReason || null,
|
||||||
|
p_submission_id: submissionId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
await fetchVersions();
|
||||||
|
return data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating version:', error);
|
||||||
|
toast.error('Failed to create version');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entityType && entityId) {
|
||||||
|
fetchVersions();
|
||||||
|
}
|
||||||
|
}, [entityType, entityId]);
|
||||||
|
|
||||||
|
// Set up realtime subscription for version changes
|
||||||
|
useEffect(() => {
|
||||||
|
const channel = supabase
|
||||||
|
.channel('entity_versions_changes')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'entity_versions',
|
||||||
|
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
fetchVersions();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [entityType, entityId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
versions,
|
||||||
|
currentVersion,
|
||||||
|
loading,
|
||||||
|
fieldHistory,
|
||||||
|
fetchVersions,
|
||||||
|
fetchFieldHistory,
|
||||||
|
compareVersions,
|
||||||
|
rollbackToVersion,
|
||||||
|
createVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -324,6 +324,279 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
entity_field_history: {
|
||||||
|
Row: {
|
||||||
|
change_type: string
|
||||||
|
created_at: string
|
||||||
|
field_name: string
|
||||||
|
id: string
|
||||||
|
new_value: Json | null
|
||||||
|
old_value: Json | null
|
||||||
|
version_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
change_type: string
|
||||||
|
created_at?: string
|
||||||
|
field_name: string
|
||||||
|
id?: string
|
||||||
|
new_value?: Json | null
|
||||||
|
old_value?: Json | null
|
||||||
|
version_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
change_type?: string
|
||||||
|
created_at?: string
|
||||||
|
field_name?: string
|
||||||
|
id?: string
|
||||||
|
new_value?: Json | null
|
||||||
|
old_value?: Json | null
|
||||||
|
version_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "entity_field_history_version_id_fkey"
|
||||||
|
columns: ["version_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "entity_versions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
entity_relationships_history: {
|
||||||
|
Row: {
|
||||||
|
change_type: string
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
old_related_entity_id: string | null
|
||||||
|
related_entity_id: string | null
|
||||||
|
related_entity_type: string
|
||||||
|
relationship_type: string
|
||||||
|
version_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
change_type: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
old_related_entity_id?: string | null
|
||||||
|
related_entity_id?: string | null
|
||||||
|
related_entity_type: string
|
||||||
|
relationship_type: string
|
||||||
|
version_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
change_type?: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
old_related_entity_id?: string | null
|
||||||
|
related_entity_id?: string | null
|
||||||
|
related_entity_type?: string
|
||||||
|
relationship_type?: string
|
||||||
|
version_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "entity_relationships_history_version_id_fkey"
|
||||||
|
columns: ["version_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "entity_versions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
entity_versions: {
|
||||||
|
Row: {
|
||||||
|
change_reason: string | null
|
||||||
|
change_type: Database["public"]["Enums"]["version_change_type"]
|
||||||
|
changed_at: string
|
||||||
|
changed_by: string | null
|
||||||
|
entity_id: string
|
||||||
|
entity_type: string
|
||||||
|
id: string
|
||||||
|
ip_address_hash: string | null
|
||||||
|
is_current: boolean
|
||||||
|
metadata: Json | null
|
||||||
|
submission_id: string | null
|
||||||
|
version_data: Json
|
||||||
|
version_number: number
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
change_reason?: string | null
|
||||||
|
change_type?: Database["public"]["Enums"]["version_change_type"]
|
||||||
|
changed_at?: string
|
||||||
|
changed_by?: string | null
|
||||||
|
entity_id: string
|
||||||
|
entity_type: string
|
||||||
|
id?: string
|
||||||
|
ip_address_hash?: string | null
|
||||||
|
is_current?: boolean
|
||||||
|
metadata?: Json | null
|
||||||
|
submission_id?: string | null
|
||||||
|
version_data: Json
|
||||||
|
version_number: number
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
change_reason?: string | null
|
||||||
|
change_type?: Database["public"]["Enums"]["version_change_type"]
|
||||||
|
changed_at?: string
|
||||||
|
changed_by?: string | null
|
||||||
|
entity_id?: string
|
||||||
|
entity_type?: string
|
||||||
|
id?: string
|
||||||
|
ip_address_hash?: string | null
|
||||||
|
is_current?: boolean
|
||||||
|
metadata?: Json | null
|
||||||
|
submission_id?: string | null
|
||||||
|
version_data?: Json
|
||||||
|
version_number?: number
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "entity_versions_submission_id_fkey"
|
||||||
|
columns: ["submission_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "content_submissions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
historical_parks: {
|
||||||
|
Row: {
|
||||||
|
closure_reason: string | null
|
||||||
|
created_at: string
|
||||||
|
final_state_data: Json
|
||||||
|
id: string
|
||||||
|
location_id: string | null
|
||||||
|
name: string
|
||||||
|
operated_from: string | null
|
||||||
|
operated_until: string | null
|
||||||
|
original_park_id: string | null
|
||||||
|
slug: string
|
||||||
|
successor_park_id: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
closure_reason?: string | null
|
||||||
|
created_at?: string
|
||||||
|
final_state_data: Json
|
||||||
|
id?: string
|
||||||
|
location_id?: string | null
|
||||||
|
name: string
|
||||||
|
operated_from?: string | null
|
||||||
|
operated_until?: string | null
|
||||||
|
original_park_id?: string | null
|
||||||
|
slug: string
|
||||||
|
successor_park_id?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
closure_reason?: string | null
|
||||||
|
created_at?: string
|
||||||
|
final_state_data?: Json
|
||||||
|
id?: string
|
||||||
|
location_id?: string | null
|
||||||
|
name?: string
|
||||||
|
operated_from?: string | null
|
||||||
|
operated_until?: string | null
|
||||||
|
original_park_id?: string | null
|
||||||
|
slug?: string
|
||||||
|
successor_park_id?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_parks_location_id_fkey"
|
||||||
|
columns: ["location_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "locations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_parks_original_park_id_fkey"
|
||||||
|
columns: ["original_park_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "parks"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_parks_successor_park_id_fkey"
|
||||||
|
columns: ["successor_park_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "parks"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
historical_rides: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
final_state_data: Json
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
operated_from: string | null
|
||||||
|
operated_until: string | null
|
||||||
|
original_ride_id: string | null
|
||||||
|
park_id: string | null
|
||||||
|
relocated_to_park_id: string | null
|
||||||
|
removal_reason: string | null
|
||||||
|
slug: string
|
||||||
|
successor_ride_id: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
final_state_data: Json
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
operated_from?: string | null
|
||||||
|
operated_until?: string | null
|
||||||
|
original_ride_id?: string | null
|
||||||
|
park_id?: string | null
|
||||||
|
relocated_to_park_id?: string | null
|
||||||
|
removal_reason?: string | null
|
||||||
|
slug: string
|
||||||
|
successor_ride_id?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
final_state_data?: Json
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
operated_from?: string | null
|
||||||
|
operated_until?: string | null
|
||||||
|
original_ride_id?: string | null
|
||||||
|
park_id?: string | null
|
||||||
|
relocated_to_park_id?: string | null
|
||||||
|
removal_reason?: string | null
|
||||||
|
slug?: string
|
||||||
|
successor_ride_id?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_rides_original_ride_id_fkey"
|
||||||
|
columns: ["original_ride_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "rides"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_rides_park_id_fkey"
|
||||||
|
columns: ["park_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "parks"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_rides_relocated_to_park_id_fkey"
|
||||||
|
columns: ["relocated_to_park_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "parks"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "historical_rides_successor_ride_id_fkey"
|
||||||
|
columns: ["successor_ride_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "rides"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
locations: {
|
locations: {
|
||||||
Row: {
|
Row: {
|
||||||
city: string | null
|
city: string | null
|
||||||
@@ -482,6 +755,58 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
park_location_history: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
moved_at: string
|
||||||
|
new_location_id: string
|
||||||
|
old_location_id: string | null
|
||||||
|
park_id: string
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
moved_at: string
|
||||||
|
new_location_id: string
|
||||||
|
old_location_id?: string | null
|
||||||
|
park_id: string
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
moved_at?: string
|
||||||
|
new_location_id?: string
|
||||||
|
old_location_id?: string | null
|
||||||
|
park_id?: string
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "park_location_history_new_location_id_fkey"
|
||||||
|
columns: ["new_location_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "locations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "park_location_history_old_location_id_fkey"
|
||||||
|
columns: ["old_location_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "locations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "park_location_history_park_id_fkey"
|
||||||
|
columns: ["park_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "parks"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
park_operating_hours: {
|
park_operating_hours: {
|
||||||
Row: {
|
Row: {
|
||||||
closing_time: string | null
|
closing_time: string | null
|
||||||
@@ -2168,6 +2493,45 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
version_diffs: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
diff_data: Json
|
||||||
|
from_version_id: string
|
||||||
|
id: string
|
||||||
|
to_version_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
diff_data: Json
|
||||||
|
from_version_id: string
|
||||||
|
id?: string
|
||||||
|
to_version_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
diff_data?: Json
|
||||||
|
from_version_id?: string
|
||||||
|
id?: string
|
||||||
|
to_version_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "version_diffs_from_version_id_fkey"
|
||||||
|
columns: ["from_version_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "entity_versions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "version_diffs_to_version_id_fkey"
|
||||||
|
columns: ["to_version_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "entity_versions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Views: {
|
Views: {
|
||||||
moderation_sla_metrics: {
|
moderation_sla_metrics: {
|
||||||
@@ -2230,6 +2594,26 @@ export type Database = {
|
|||||||
Args: Record<PropertyKey, never>
|
Args: Record<PropertyKey, never>
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
compare_versions: {
|
||||||
|
Args: { p_from_version_id: string; p_to_version_id: string }
|
||||||
|
Returns: Json
|
||||||
|
}
|
||||||
|
create_entity_version: {
|
||||||
|
Args: {
|
||||||
|
p_change_reason?: string
|
||||||
|
p_change_type?: Database["public"]["Enums"]["version_change_type"]
|
||||||
|
p_changed_by: string
|
||||||
|
p_entity_id: string
|
||||||
|
p_entity_type: string
|
||||||
|
p_submission_id?: string
|
||||||
|
p_version_data: Json
|
||||||
|
}
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
|
create_field_history_entries: {
|
||||||
|
Args: { p_new_data: Json; p_old_data: Json; p_version_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
extend_submission_lock: {
|
extend_submission_lock: {
|
||||||
Args: {
|
Args: {
|
||||||
extension_duration?: unknown
|
extension_duration?: unknown
|
||||||
@@ -2306,6 +2690,16 @@ export type Database = {
|
|||||||
Args: { moderator_id: string; submission_id: string }
|
Args: { moderator_id: string; submission_id: string }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
}
|
}
|
||||||
|
rollback_to_version: {
|
||||||
|
Args: {
|
||||||
|
p_changed_by: string
|
||||||
|
p_entity_id: string
|
||||||
|
p_entity_type: string
|
||||||
|
p_reason: string
|
||||||
|
p_target_version_id: string
|
||||||
|
}
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
update_company_ratings: {
|
update_company_ratings: {
|
||||||
Args: { target_company_id: string }
|
Args: { target_company_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
@@ -2325,6 +2719,12 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
app_role: "admin" | "moderator" | "user" | "superuser"
|
app_role: "admin" | "moderator" | "user" | "superuser"
|
||||||
|
version_change_type:
|
||||||
|
| "created"
|
||||||
|
| "updated"
|
||||||
|
| "deleted"
|
||||||
|
| "restored"
|
||||||
|
| "archived"
|
||||||
}
|
}
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -2453,6 +2853,13 @@ export const Constants = {
|
|||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
app_role: ["admin", "moderator", "user", "superuser"],
|
app_role: ["admin", "moderator", "user", "superuser"],
|
||||||
|
version_change_type: [
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
"deleted",
|
||||||
|
"restored",
|
||||||
|
"archived",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- COMPREHENSIVE VERSIONING SYSTEM
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Create enum for change types
|
||||||
|
CREATE TYPE public.version_change_type AS ENUM ('created', 'updated', 'deleted', 'restored', 'archived');
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CORE VERSIONING TABLES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Master version registry with complete snapshots
|
||||||
|
CREATE TABLE public.entity_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
version_number INTEGER NOT NULL,
|
||||||
|
version_data JSONB NOT NULL,
|
||||||
|
changed_by UUID REFERENCES auth.users(id),
|
||||||
|
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
change_reason TEXT,
|
||||||
|
change_type public.version_change_type NOT NULL DEFAULT 'updated',
|
||||||
|
submission_id UUID REFERENCES public.content_submissions(id),
|
||||||
|
is_current BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ip_address_hash TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE(entity_type, entity_id, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Granular field-level change tracking
|
||||||
|
CREATE TABLE public.entity_field_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
version_id UUID NOT NULL REFERENCES public.entity_versions(id) ON DELETE CASCADE,
|
||||||
|
field_name TEXT NOT NULL,
|
||||||
|
old_value JSONB,
|
||||||
|
new_value JSONB,
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'removed')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Relationship change tracking
|
||||||
|
CREATE TABLE public.entity_relationships_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
version_id UUID NOT NULL REFERENCES public.entity_versions(id) ON DELETE CASCADE,
|
||||||
|
relationship_type TEXT NOT NULL,
|
||||||
|
related_entity_type TEXT NOT NULL,
|
||||||
|
related_entity_id UUID,
|
||||||
|
old_related_entity_id UUID,
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN ('linked', 'unlinked', 'changed')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cached version comparisons for performance
|
||||||
|
CREATE TABLE public.version_diffs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
from_version_id UUID NOT NULL REFERENCES public.entity_versions(id) ON DELETE CASCADE,
|
||||||
|
to_version_id UUID NOT NULL REFERENCES public.entity_versions(id) ON DELETE CASCADE,
|
||||||
|
diff_data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(from_version_id, to_version_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- HISTORICAL STATE TABLES (Real-world lifecycle)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Historical parks (closed, demolished, transformed)
|
||||||
|
CREATE TABLE public.historical_parks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
original_park_id UUID REFERENCES public.parks(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
operated_from DATE,
|
||||||
|
operated_until DATE,
|
||||||
|
closure_reason TEXT,
|
||||||
|
successor_park_id UUID REFERENCES public.parks(id),
|
||||||
|
final_state_data JSONB NOT NULL,
|
||||||
|
location_id UUID REFERENCES public.locations(id),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Historical rides (removed, relocated, demolished)
|
||||||
|
CREATE TABLE public.historical_rides (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
original_ride_id UUID REFERENCES public.rides(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
park_id UUID REFERENCES public.parks(id),
|
||||||
|
operated_from DATE,
|
||||||
|
operated_until DATE,
|
||||||
|
removal_reason TEXT,
|
||||||
|
relocated_to_park_id UUID REFERENCES public.parks(id),
|
||||||
|
successor_ride_id UUID REFERENCES public.rides(id),
|
||||||
|
final_state_data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Park location history (relocations, expansions)
|
||||||
|
CREATE TABLE public.park_location_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
park_id UUID NOT NULL REFERENCES public.parks(id) ON DELETE CASCADE,
|
||||||
|
old_location_id UUID REFERENCES public.locations(id),
|
||||||
|
new_location_id UUID NOT NULL REFERENCES public.locations(id),
|
||||||
|
moved_at DATE NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INDEXES FOR PERFORMANCE
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Lookup indexes
|
||||||
|
CREATE INDEX idx_entity_versions_entity ON public.entity_versions(entity_type, entity_id);
|
||||||
|
CREATE INDEX idx_entity_versions_changed_by ON public.entity_versions(changed_by);
|
||||||
|
CREATE INDEX idx_entity_versions_submission ON public.entity_versions(submission_id) WHERE submission_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Timeline index
|
||||||
|
CREATE INDEX idx_entity_versions_changed_at ON public.entity_versions(changed_at DESC);
|
||||||
|
|
||||||
|
-- Current version partial index (performance optimization)
|
||||||
|
CREATE INDEX idx_entity_versions_current ON public.entity_versions(entity_type, entity_id) WHERE is_current = true;
|
||||||
|
|
||||||
|
-- Field history indexes
|
||||||
|
CREATE INDEX idx_field_history_version ON public.entity_field_history(version_id);
|
||||||
|
CREATE INDEX idx_field_history_field ON public.entity_field_history(field_name);
|
||||||
|
|
||||||
|
-- Relationship history indexes
|
||||||
|
CREATE INDEX idx_relationships_history_version ON public.entity_relationships_history(version_id);
|
||||||
|
|
||||||
|
-- Historical entities indexes
|
||||||
|
CREATE INDEX idx_historical_parks_original ON public.historical_parks(original_park_id);
|
||||||
|
CREATE INDEX idx_historical_rides_original ON public.historical_rides(original_ride_id);
|
||||||
|
CREATE INDEX idx_historical_rides_park ON public.historical_rides(park_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CORE VERSIONING FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Create a new entity version
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_entity_version(
|
||||||
|
p_entity_type TEXT,
|
||||||
|
p_entity_id UUID,
|
||||||
|
p_version_data JSONB,
|
||||||
|
p_changed_by UUID,
|
||||||
|
p_change_reason TEXT DEFAULT NULL,
|
||||||
|
p_submission_id UUID DEFAULT NULL,
|
||||||
|
p_change_type public.version_change_type DEFAULT 'updated'
|
||||||
|
)
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_version_id UUID;
|
||||||
|
v_version_number INTEGER;
|
||||||
|
v_old_data JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- Get the previous version data
|
||||||
|
SELECT version_data INTO v_old_data
|
||||||
|
FROM public.entity_versions
|
||||||
|
WHERE entity_type = p_entity_type
|
||||||
|
AND entity_id = p_entity_id
|
||||||
|
AND is_current = true;
|
||||||
|
|
||||||
|
-- Mark previous version as not current
|
||||||
|
UPDATE public.entity_versions
|
||||||
|
SET is_current = false
|
||||||
|
WHERE entity_type = p_entity_type
|
||||||
|
AND entity_id = p_entity_id
|
||||||
|
AND is_current = true;
|
||||||
|
|
||||||
|
-- Get next version number
|
||||||
|
SELECT COALESCE(MAX(version_number), 0) + 1 INTO v_version_number
|
||||||
|
FROM public.entity_versions
|
||||||
|
WHERE entity_type = p_entity_type
|
||||||
|
AND entity_id = p_entity_id;
|
||||||
|
|
||||||
|
-- Create new version
|
||||||
|
INSERT INTO public.entity_versions (
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
version_number,
|
||||||
|
version_data,
|
||||||
|
changed_by,
|
||||||
|
change_reason,
|
||||||
|
change_type,
|
||||||
|
submission_id,
|
||||||
|
is_current
|
||||||
|
) VALUES (
|
||||||
|
p_entity_type,
|
||||||
|
p_entity_id,
|
||||||
|
v_version_number,
|
||||||
|
p_version_data,
|
||||||
|
p_changed_by,
|
||||||
|
p_change_reason,
|
||||||
|
p_change_type,
|
||||||
|
p_submission_id,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_version_id;
|
||||||
|
|
||||||
|
-- Create field-level history if there's old data
|
||||||
|
IF v_old_data IS NOT NULL THEN
|
||||||
|
PERFORM public.create_field_history_entries(v_version_id, v_old_data, p_version_data);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_version_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Create field history entries by comparing old and new data
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_field_history_entries(
|
||||||
|
p_version_id UUID,
|
||||||
|
p_old_data JSONB,
|
||||||
|
p_new_data JSONB
|
||||||
|
)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_key TEXT;
|
||||||
|
v_old_value JSONB;
|
||||||
|
v_new_value JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- Check for modified and added fields
|
||||||
|
FOR v_key IN SELECT jsonb_object_keys(p_new_data)
|
||||||
|
LOOP
|
||||||
|
v_old_value := p_old_data -> v_key;
|
||||||
|
v_new_value := p_new_data -> v_key;
|
||||||
|
|
||||||
|
IF v_old_value IS NULL THEN
|
||||||
|
-- Field was added
|
||||||
|
INSERT INTO public.entity_field_history (
|
||||||
|
version_id, field_name, old_value, new_value, change_type
|
||||||
|
) VALUES (
|
||||||
|
p_version_id, v_key, NULL, v_new_value, 'added'
|
||||||
|
);
|
||||||
|
ELSIF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||||
|
-- Field was modified
|
||||||
|
INSERT INTO public.entity_field_history (
|
||||||
|
version_id, field_name, old_value, new_value, change_type
|
||||||
|
) VALUES (
|
||||||
|
p_version_id, v_key, v_old_value, v_new_value, 'modified'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Check for removed fields
|
||||||
|
FOR v_key IN SELECT jsonb_object_keys(p_old_data)
|
||||||
|
LOOP
|
||||||
|
IF NOT (p_new_data ? v_key) THEN
|
||||||
|
v_old_value := p_old_data -> v_key;
|
||||||
|
INSERT INTO public.entity_field_history (
|
||||||
|
version_id, field_name, old_value, new_value, change_type
|
||||||
|
) VALUES (
|
||||||
|
p_version_id, v_key, v_old_value, NULL, 'removed'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Compare two versions and cache the diff
|
||||||
|
CREATE OR REPLACE FUNCTION public.compare_versions(
|
||||||
|
p_from_version_id UUID,
|
||||||
|
p_to_version_id UUID
|
||||||
|
)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_cached_diff JSONB;
|
||||||
|
v_from_data JSONB;
|
||||||
|
v_to_data JSONB;
|
||||||
|
v_diff JSONB;
|
||||||
|
BEGIN
|
||||||
|
-- Check cache first
|
||||||
|
SELECT diff_data INTO v_cached_diff
|
||||||
|
FROM public.version_diffs
|
||||||
|
WHERE from_version_id = p_from_version_id
|
||||||
|
AND to_version_id = p_to_version_id;
|
||||||
|
|
||||||
|
IF v_cached_diff IS NOT NULL THEN
|
||||||
|
RETURN v_cached_diff;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get version data
|
||||||
|
SELECT version_data INTO v_from_data
|
||||||
|
FROM public.entity_versions
|
||||||
|
WHERE id = p_from_version_id;
|
||||||
|
|
||||||
|
SELECT version_data INTO v_to_data
|
||||||
|
FROM public.entity_versions
|
||||||
|
WHERE id = p_to_version_id;
|
||||||
|
|
||||||
|
-- Build diff (simple approach - list all changed fields)
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
key,
|
||||||
|
jsonb_build_object(
|
||||||
|
'from', v_from_data -> key,
|
||||||
|
'to', v_to_data -> key
|
||||||
|
)
|
||||||
|
) INTO v_diff
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT key
|
||||||
|
FROM (
|
||||||
|
SELECT jsonb_object_keys(v_from_data) AS key
|
||||||
|
UNION
|
||||||
|
SELECT jsonb_object_keys(v_to_data) AS key
|
||||||
|
) keys
|
||||||
|
WHERE (v_from_data -> key) IS DISTINCT FROM (v_to_data -> key)
|
||||||
|
) changed_keys;
|
||||||
|
|
||||||
|
-- Cache the diff
|
||||||
|
INSERT INTO public.version_diffs (from_version_id, to_version_id, diff_data)
|
||||||
|
VALUES (p_from_version_id, p_to_version_id, v_diff)
|
||||||
|
ON CONFLICT (from_version_id, to_version_id) DO UPDATE
|
||||||
|
SET diff_data = EXCLUDED.diff_data;
|
||||||
|
|
||||||
|
RETURN v_diff;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Rollback entity to a previous version
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 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'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update the actual entity table (simplified - in production would need dynamic SQL)
|
||||||
|
-- This is a placeholder - actual implementation would use dynamic SQL to update each field
|
||||||
|
|
||||||
|
RETURN v_new_version_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE public.entity_versions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.entity_field_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.entity_relationships_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.version_diffs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.historical_parks ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.historical_rides ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.park_location_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Version viewing policies
|
||||||
|
CREATE POLICY "Public can view current versions"
|
||||||
|
ON public.entity_versions FOR SELECT
|
||||||
|
USING (is_current = true);
|
||||||
|
|
||||||
|
CREATE POLICY "Moderators can view all versions"
|
||||||
|
ON public.entity_versions FOR SELECT
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view their own changes"
|
||||||
|
ON public.entity_versions FOR SELECT
|
||||||
|
USING (changed_by = auth.uid());
|
||||||
|
|
||||||
|
-- Version creation policies
|
||||||
|
CREATE POLICY "System can create versions"
|
||||||
|
ON public.entity_versions FOR INSERT
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Moderators can manage versions
|
||||||
|
CREATE POLICY "Moderators can update versions"
|
||||||
|
ON public.entity_versions FOR UPDATE
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
-- Field history policies
|
||||||
|
CREATE POLICY "Moderators view field history"
|
||||||
|
ON public.entity_field_history FOR SELECT
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "System can create field history"
|
||||||
|
ON public.entity_field_history FOR INSERT
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Relationship history policies
|
||||||
|
CREATE POLICY "Moderators view relationship history"
|
||||||
|
ON public.entity_relationships_history FOR SELECT
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "System can create relationship history"
|
||||||
|
ON public.entity_relationships_history FOR INSERT
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Version diffs policies
|
||||||
|
CREATE POLICY "Moderators view diffs"
|
||||||
|
ON public.version_diffs FOR SELECT
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "System can create diffs"
|
||||||
|
ON public.version_diffs FOR INSERT
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Historical entities policies
|
||||||
|
CREATE POLICY "Public read historical parks"
|
||||||
|
ON public.historical_parks FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Moderators manage historical parks"
|
||||||
|
ON public.historical_parks FOR ALL
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Public read historical rides"
|
||||||
|
ON public.historical_rides FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Moderators manage historical rides"
|
||||||
|
ON public.historical_rides FOR ALL
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Moderators view location history"
|
||||||
|
ON public.park_location_history FOR SELECT
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Moderators manage location history"
|
||||||
|
ON public.park_location_history FOR ALL
|
||||||
|
USING (is_moderator(auth.uid()));
|
||||||
Reference in New Issue
Block a user