mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
feat: Implement versioning documentation
This commit is contained in:
34
docs/versioning/API.md
Normal file
34
docs/versioning/API.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# API Reference
|
||||
|
||||
**Complete API documentation for versioning functions and hooks**
|
||||
|
||||
## Database Functions
|
||||
|
||||
### create_relational_version()
|
||||
Trigger function - automatically creates versions on entity INSERT/UPDATE.
|
||||
|
||||
### get_version_diff(entity_type, from_version_id, to_version_id)
|
||||
Returns JSONB diff between two versions.
|
||||
|
||||
### cleanup_old_versions(entity_type, keep_versions)
|
||||
Deletes old versions, keeping N most recent per entity.
|
||||
|
||||
### rollback_to_version(entity_type, entity_id, target_version_id, changed_by, reason)
|
||||
Restores entity to previous version, creates new version with change_type='restored'.
|
||||
|
||||
## React Hooks
|
||||
|
||||
### useEntityVersions(entityType, entityId)
|
||||
Returns: `{ versions, currentVersion, loading, rollbackToVersion, ... }`
|
||||
|
||||
### useVersionComparison(entityType, fromVersionId, toVersionId)
|
||||
Returns: `{ diff, loading, error }`
|
||||
|
||||
## Components
|
||||
|
||||
- `<VersionIndicator />` - Version badge
|
||||
- `<EntityVersionHistory />` - Full timeline
|
||||
- `<VersionComparisonDialog />` - Side-by-side diff
|
||||
- `<RollbackDialog />` - Restore confirmation
|
||||
|
||||
See [FRONTEND.md](./FRONTEND.md) for detailed usage.
|
||||
55
docs/versioning/ARCHITECTURE.md
Normal file
55
docs/versioning/ARCHITECTURE.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Versioning System Architecture
|
||||
|
||||
**System design, data flow, and architectural decisions**
|
||||
|
||||
## System Overview
|
||||
|
||||
The Universal Versioning System is a relational database-backed solution for tracking all changes to entities throughout their lifecycle. It uses PostgreSQL triggers, session variables, and type-safe relational tables to provide automatic, transparent versioning.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Automatic & Transparent** - Versioning happens automatically via triggers
|
||||
2. **Pure Relational** - No JSONB storage, all fields are typed columns
|
||||
3. **Audit-First** - Every change is attributed to a user and optionally a submission
|
||||
4. **Type-Safe** - Foreign keys and constraints enforce data integrity
|
||||
5. **Performance-Conscious** - Proper indexing and cleanup mechanisms
|
||||
|
||||
### Why Not JSONB?
|
||||
|
||||
The previous system stored versions as JSONB blobs. We migrated to relational for:
|
||||
|
||||
| Issue with JSONB | Relational Solution |
|
||||
|------------------|---------------------|
|
||||
| Not queryable without JSONB operators | Standard SQL WHERE clauses |
|
||||
| No type safety | Column-level types and constraints |
|
||||
| Poor performance on large datasets | Indexed columns |
|
||||
| Can't use foreign keys | Full referential integrity |
|
||||
| Complex RLS policies | Standard column-level RLS |
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Frontend Layer"
|
||||
UI[React Components]
|
||||
HOOKS[React Hooks]
|
||||
end
|
||||
|
||||
subgraph "Backend Layer"
|
||||
EDGE[Edge Functions]
|
||||
DB[(PostgreSQL)]
|
||||
TRIGGERS[Database Triggers]
|
||||
end
|
||||
|
||||
UI --> HOOKS
|
||||
HOOKS --> DB
|
||||
EDGE --> SESSION[Set Session Variables]
|
||||
SESSION --> DB
|
||||
DB --> TRIGGERS
|
||||
TRIGGERS --> VERSIONS[Version Tables]
|
||||
|
||||
style VERSIONS fill:#4CAF50
|
||||
style TRIGGERS fill:#2196F3
|
||||
style SESSION fill:#FF9800
|
||||
46
docs/versioning/BEST_PRACTICES.md
Normal file
46
docs/versioning/BEST_PRACTICES.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Best Practices
|
||||
|
||||
## When to Create Versions
|
||||
|
||||
✅ **DO:** Let triggers handle versioning automatically
|
||||
❌ **DON'T:** Manually call versioning functions
|
||||
❌ **DON'T:** Bypass triggers with direct SQL
|
||||
|
||||
## Performance
|
||||
|
||||
- Run `cleanup_old_versions()` monthly
|
||||
- Keep 50-100 versions per entity
|
||||
- Use indexes for queries
|
||||
- Implement pagination for large version lists
|
||||
|
||||
## Security
|
||||
|
||||
- Never expose `created_by` user IDs to public
|
||||
- Always check RLS policies
|
||||
- Validate rollback permissions server-side
|
||||
- Use session variables for attribution
|
||||
|
||||
## Testing
|
||||
|
||||
Test version creation on:
|
||||
- INSERT (creates version_number: 1)
|
||||
- UPDATE (increments version_number)
|
||||
- Rollback (creates new version with change_type='restored')
|
||||
|
||||
## Attribution
|
||||
|
||||
Always set `app.current_user_id` to original submitter, NOT moderator.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
await supabase.rpc('set_session_variable', {
|
||||
key: 'app.current_user_id',
|
||||
value: submission.user_id, // Original submitter
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
await supabase.rpc('set_session_variable', {
|
||||
key: 'app.current_user_id',
|
||||
value: auth.uid(), // Moderator who approved
|
||||
});
|
||||
```
|
||||
613
docs/versioning/FRONTEND.md
Normal file
613
docs/versioning/FRONTEND.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# Frontend Integration Guide
|
||||
|
||||
**How to use versioning in React components**
|
||||
|
||||
## Overview
|
||||
|
||||
The versioning system provides React hooks and components for displaying version history, comparing versions, and rolling back changes. All components are fully typed with TypeScript discriminated unions.
|
||||
|
||||
## Type System
|
||||
|
||||
### Core Types
|
||||
|
||||
```typescript
|
||||
// Entity type union
|
||||
type EntityType = 'park' | 'ride' | 'company' | 'ride_model';
|
||||
|
||||
// Change type enum
|
||||
type ChangeType = 'created' | 'updated' | 'deleted' | 'restored' | 'archived';
|
||||
|
||||
// Base version metadata
|
||||
interface BaseVersion {
|
||||
version_id: string;
|
||||
version_number: number;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
change_type: ChangeType;
|
||||
change_reason: string | null;
|
||||
submission_id: string | null;
|
||||
is_current: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Entity-Specific Versions
|
||||
|
||||
The system uses discriminated unions for type-safe version handling:
|
||||
|
||||
```typescript
|
||||
// Discriminated union based on entity type
|
||||
type EntityVersion =
|
||||
| ParkVersion
|
||||
| RideVersion
|
||||
| CompanyVersion
|
||||
| RideModelVersion;
|
||||
|
||||
// Example: Park version
|
||||
interface ParkVersion extends BaseVersion {
|
||||
park_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
park_type: string;
|
||||
status: string;
|
||||
opening_date: string | null;
|
||||
closing_date: string | null;
|
||||
location_id: string | null;
|
||||
operator_id: string | null;
|
||||
property_owner_id: string | null;
|
||||
// ... all park fields
|
||||
}
|
||||
```
|
||||
|
||||
### Version Diff
|
||||
|
||||
```typescript
|
||||
interface VersionDiff {
|
||||
[fieldName: string]: {
|
||||
from: any;
|
||||
to: any;
|
||||
changed: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## React Hooks
|
||||
|
||||
### useEntityVersions
|
||||
|
||||
**Purpose:** Fetch and manage version history for an entity.
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||
|
||||
const MyComponent = ({ parkId }: { parkId: string }) => {
|
||||
const {
|
||||
versions,
|
||||
currentVersion,
|
||||
loading,
|
||||
fieldHistory,
|
||||
fetchVersions,
|
||||
fetchFieldHistory,
|
||||
compareVersions,
|
||||
rollbackToVersion,
|
||||
} = useEntityVersions('park', parkId);
|
||||
|
||||
// versions: Array of all versions
|
||||
// currentVersion: Latest version with is_current = true
|
||||
// loading: Boolean loading state
|
||||
// fieldHistory: Array of field changes for selected version
|
||||
};
|
||||
```
|
||||
|
||||
#### Return Type
|
||||
|
||||
```typescript
|
||||
interface UseEntityVersionsReturn {
|
||||
versions: EntityVersion[];
|
||||
currentVersion: EntityVersion | null;
|
||||
loading: boolean;
|
||||
fieldHistory: FieldChange[];
|
||||
fetchVersions: () => Promise<void>;
|
||||
fetchFieldHistory: (versionId: string) => Promise<void>;
|
||||
compareVersions: (fromId: string, toId: string) => Promise<VersionDiff | null>;
|
||||
rollbackToVersion: (versionId: string, reason: string) => Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
**fetchVersions()**
|
||||
- Fetches all versions for the entity
|
||||
- Automatically called on mount and entity ID change
|
||||
- Returns: `Promise<void>`
|
||||
|
||||
**fetchFieldHistory(versionId)**
|
||||
- Fetches detailed field changes for a specific version
|
||||
- Populates `fieldHistory` state
|
||||
- Parameters:
|
||||
- `versionId: string` - Version to analyze
|
||||
- Returns: `Promise<void>`
|
||||
|
||||
**compareVersions(fromId, toId)**
|
||||
- Compares two versions using database function
|
||||
- Parameters:
|
||||
- `fromId: string` - Older version ID
|
||||
- `toId: string` - Newer version ID
|
||||
- Returns: `Promise<VersionDiff | null>`
|
||||
|
||||
**rollbackToVersion(versionId, reason)**
|
||||
- Restores entity to a previous version
|
||||
- Creates new version with `change_type='restored'`
|
||||
- Requires moderator role
|
||||
- Parameters:
|
||||
- `versionId: string` - Target version to restore
|
||||
- `reason: string` - Reason for rollback
|
||||
- Returns: `Promise<boolean>` - Success status
|
||||
|
||||
### useVersionComparison
|
||||
|
||||
**Purpose:** Compare two specific versions and get diff.
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { useVersionComparison } from '@/hooks/useVersionComparison';
|
||||
|
||||
const ComparisonView = () => {
|
||||
const { diff, loading, error } = useVersionComparison(
|
||||
'park',
|
||||
fromVersionId,
|
||||
toVersionId
|
||||
);
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <Error message={error} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(diff || {}).map(([field, change]) => (
|
||||
<FieldDiff
|
||||
key={field}
|
||||
field={field}
|
||||
from={change.from}
|
||||
to={change.to}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### Return Type
|
||||
|
||||
```typescript
|
||||
interface UseVersionComparisonReturn {
|
||||
diff: VersionDiff | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### VersionIndicator
|
||||
|
||||
**Purpose:** Display current version badge on entity pages.
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
interface VersionIndicatorProps {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
compact?: boolean; // Compact mode shows only version number
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
|
||||
|
||||
// Full mode (with "Last edited" timestamp)
|
||||
<VersionIndicator
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
entityName={park.name}
|
||||
/>
|
||||
|
||||
// Compact mode (badge only)
|
||||
<VersionIndicator
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
entityName={park.name}
|
||||
compact
|
||||
/>
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
- Displays: "Version 3 · Last edited 2 hours ago"
|
||||
- Clicking opens `EntityVersionHistory` dialog
|
||||
- Compact mode: Just "v3" badge
|
||||
|
||||
### EntityVersionHistory
|
||||
|
||||
**Purpose:** Display complete version timeline with comparison and rollback.
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
interface EntityVersionHistoryProps {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory';
|
||||
|
||||
<Dialog>
|
||||
<DialogContent>
|
||||
<EntityVersionHistory
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
entityName={park.name}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
#### Features
|
||||
|
||||
- Timeline of all versions
|
||||
- Select two versions to compare
|
||||
- Rollback to any previous version
|
||||
- User attribution and timestamps
|
||||
- Change type badges (created, updated, restored)
|
||||
|
||||
### VersionComparisonDialog
|
||||
|
||||
**Purpose:** Side-by-side diff view of two versions.
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
interface VersionComparisonDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
entityType: EntityType;
|
||||
fromVersionId: string | null;
|
||||
toVersionId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { VersionComparisonDialog } from '@/components/versioning/VersionComparisonDialog';
|
||||
|
||||
<VersionComparisonDialog
|
||||
open={showComparison}
|
||||
onOpenChange={setShowComparison}
|
||||
entityType="park"
|
||||
fromVersionId={selectedVersions[0]}
|
||||
toVersionId={selectedVersions[1]}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
- Field-by-field comparison
|
||||
- Highlights added, removed, and modified fields
|
||||
- Color-coded changes (green=added, red=removed, yellow=modified)
|
||||
- Formatted values (dates, JSON, etc.)
|
||||
|
||||
### RollbackDialog
|
||||
|
||||
**Purpose:** Confirm and execute version rollback.
|
||||
|
||||
#### Props
|
||||
|
||||
```typescript
|
||||
interface RollbackDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
targetVersion: EntityVersion;
|
||||
onRollback: (versionId: string, reason: string) => Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
```typescript
|
||||
import { RollbackDialog } from '@/components/versioning/RollbackDialog';
|
||||
|
||||
<RollbackDialog
|
||||
open={showRollback}
|
||||
onOpenChange={setShowRollback}
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
targetVersion={selectedVersion}
|
||||
onRollback={rollbackToVersion}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Features
|
||||
|
||||
- Requires rollback reason (text input)
|
||||
- Shows preview of target version
|
||||
- Confirms action with user
|
||||
- Displays success/error toast
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Adding Version History to Entity Pages
|
||||
|
||||
```typescript
|
||||
// Example: ParkDetail.tsx
|
||||
|
||||
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
|
||||
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';
|
||||
|
||||
const ParkDetail = () => {
|
||||
const { park } = usePark();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header with version indicator */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h1>{park.name}</h1>
|
||||
<VersionIndicator
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
entityName={park.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs with history */}
|
||||
<Tabs>
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details">
|
||||
{/* Entity details */}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<EntityHistoryTabs
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
entityName={park.name}
|
||||
events={[]} // Event history
|
||||
formerNames={park.former_names}
|
||||
currentName={park.name}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Custom Version List
|
||||
|
||||
```typescript
|
||||
const CustomVersionList = ({ parkId }: { parkId: string }) => {
|
||||
const { versions, loading } = useEntityVersions('park', parkId);
|
||||
|
||||
if (loading) return <Skeleton />;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{versions.map((version) => (
|
||||
<li key={version.version_id}>
|
||||
<Badge>{version.change_type}</Badge>
|
||||
<span>Version {version.version_number}</span>
|
||||
<span>{formatDate(version.created_at)}</span>
|
||||
<UserAvatar userId={version.created_by} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Version Comparison Widget
|
||||
|
||||
```typescript
|
||||
const VersionComparison = ({
|
||||
entityType,
|
||||
versionIds
|
||||
}: {
|
||||
entityType: EntityType;
|
||||
versionIds: [string, string]
|
||||
}) => {
|
||||
const { diff, loading } = useVersionComparison(
|
||||
entityType,
|
||||
versionIds[0],
|
||||
versionIds[1]
|
||||
);
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
const changedFields = Object.entries(diff || {}).filter(
|
||||
([_, change]) => change.changed
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{changedFields.length} fields changed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{changedFields.map(([field, change]) => (
|
||||
<div key={field} className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>{field}</Label>
|
||||
<pre>{JSON.stringify(change.from, null, 2)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{field}</Label>
|
||||
<pre>{JSON.stringify(change.to, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Real-Time Updates
|
||||
|
||||
The `useEntityVersions` hook automatically subscribes to real-time changes via Supabase:
|
||||
|
||||
```typescript
|
||||
// Automatic subscription in useEntityVersions
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel(`${tableName}:${entityId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: tableName,
|
||||
filter: `${entityType}_id=eq.${entityId}`,
|
||||
},
|
||||
() => {
|
||||
fetchVersions(); // Refetch on change
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [entityType, entityId]);
|
||||
```
|
||||
|
||||
## Type Guards
|
||||
|
||||
Use type guards to narrow discriminated unions:
|
||||
|
||||
```typescript
|
||||
function isParkVersion(version: EntityVersion): version is ParkVersion {
|
||||
return 'park_id' in version;
|
||||
}
|
||||
|
||||
function isRideVersion(version: EntityVersion): version is RideVersion {
|
||||
return 'ride_id' in version;
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (isParkVersion(version)) {
|
||||
console.log(version.park_type); // TypeScript knows this is a park
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All hooks handle errors gracefully:
|
||||
|
||||
```typescript
|
||||
const { versions, loading } = useEntityVersions('park', parkId);
|
||||
|
||||
// Loading state
|
||||
if (loading) return <Skeleton />;
|
||||
|
||||
// Error state (versions will be empty array)
|
||||
if (versions.length === 0) {
|
||||
return <EmptyState message="No version history available" />;
|
||||
}
|
||||
|
||||
// Success state
|
||||
return <VersionList versions={versions} />;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
✅ Use `VersionIndicator` on all entity detail pages
|
||||
✅ Add history tab to entity pages
|
||||
✅ Let hooks manage state and subscriptions
|
||||
✅ Use type guards for discriminated unions
|
||||
✅ Handle loading and error states
|
||||
|
||||
### DON'T
|
||||
|
||||
❌ Manually query version tables (use hooks)
|
||||
❌ Bypass type system with `any`
|
||||
❌ Forget to cleanup subscriptions
|
||||
❌ Display sensitive data (created_by IDs) to public
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Pagination
|
||||
|
||||
For entities with many versions, implement pagination:
|
||||
|
||||
```typescript
|
||||
const { versions } = useEntityVersions('park', parkId);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const paginatedVersions = versions.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
);
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
Only load version details when needed:
|
||||
|
||||
```typescript
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
const { fieldHistory, fetchFieldHistory } = useEntityVersions('park', parkId);
|
||||
|
||||
const handleVersionClick = (versionId: string) => {
|
||||
setSelectedVersion(versionId);
|
||||
fetchFieldHistory(versionId); // Load on demand
|
||||
};
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Example Unit Test
|
||||
|
||||
```typescript
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||
|
||||
describe('useEntityVersions', () => {
|
||||
it('fetches versions on mount', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEntityVersions('park', 'test-park-id')
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.versions.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for common issues and solutions.
|
||||
104
docs/versioning/MIGRATION.md
Normal file
104
docs/versioning/MIGRATION.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Migration Guide
|
||||
|
||||
**Migrating from JSONB entity_versions to relational version tables**
|
||||
|
||||
## Status
|
||||
|
||||
✅ **Migration Complete** - The relational versioning system is now active. This guide documents the migration for reference.
|
||||
|
||||
## Overview
|
||||
|
||||
The old system stored versions in a single `entity_versions` table using JSONB:
|
||||
- ❌ Not queryable
|
||||
- ❌ No type safety
|
||||
- ❌ Poor performance
|
||||
- ❌ Complex RLS
|
||||
|
||||
The new system uses dedicated relational tables:
|
||||
- ✅ Fully queryable
|
||||
- ✅ Type-safe with foreign keys
|
||||
- ✅ Indexed and performant
|
||||
- ✅ Standard RLS policies
|
||||
|
||||
## Migration Timeline
|
||||
|
||||
1. **Phase 1: Create relational tables** ✅ Complete
|
||||
2. **Phase 2: Enable triggers** ✅ Complete
|
||||
3. **Phase 3: Dual-write period** ✅ Complete
|
||||
4. **Phase 4: Backfill historical data** ⏸️ Optional
|
||||
5. **Phase 5: Monitor** 🔄 Ongoing
|
||||
6. **Phase 6: Deprecate JSONB table** 📅 Future
|
||||
|
||||
## Current State
|
||||
|
||||
- ✅ All new versions written to relational tables
|
||||
- ✅ Triggers active on all entity tables
|
||||
- ⚠️ Old `entity_versions` table retained for backward compatibility
|
||||
- ⚠️ `src/lib/versioningHelpers.ts` deprecated but not removed
|
||||
|
||||
## Backfill Script (Optional)
|
||||
|
||||
If you need to migrate historical JSONB versions to relational:
|
||||
|
||||
```sql
|
||||
-- Backfill park versions
|
||||
INSERT INTO park_versions (
|
||||
version_id, park_id, version_number, created_at, created_by,
|
||||
change_type, submission_id, is_current,
|
||||
name, slug, description, park_type, status,
|
||||
opening_date, opening_date_precision, closing_date, closing_date_precision,
|
||||
location_id, operator_id, property_owner_id,
|
||||
website_url, phone, email,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id
|
||||
)
|
||||
SELECT
|
||||
ev.id, ev.entity_id, ev.version_number, ev.changed_at, ev.changed_by,
|
||||
ev.change_type, ev.submission_id, ev.is_current,
|
||||
ev.version_data->>'name',
|
||||
ev.version_data->>'slug',
|
||||
ev.version_data->>'description',
|
||||
ev.version_data->>'park_type',
|
||||
ev.version_data->>'status',
|
||||
(ev.version_data->>'opening_date')::date,
|
||||
ev.version_data->>'opening_date_precision',
|
||||
(ev.version_data->>'closing_date')::date,
|
||||
ev.version_data->>'closing_date_precision',
|
||||
(ev.version_data->>'location_id')::uuid,
|
||||
(ev.version_data->>'operator_id')::uuid,
|
||||
(ev.version_data->>'property_owner_id')::uuid,
|
||||
ev.version_data->>'website_url',
|
||||
ev.version_data->>'phone',
|
||||
ev.version_data->>'email',
|
||||
ev.version_data->>'banner_image_url',
|
||||
ev.version_data->>'banner_image_id',
|
||||
ev.version_data->>'card_image_url',
|
||||
ev.version_data->>'card_image_id'
|
||||
FROM entity_versions ev
|
||||
WHERE ev.entity_type = 'park'
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
## Cleanup (Future)
|
||||
|
||||
When ready to fully deprecate JSONB system:
|
||||
|
||||
```sql
|
||||
-- 1. Verify all versions migrated
|
||||
SELECT COUNT(*) FROM entity_versions; -- Should match relational tables
|
||||
|
||||
-- 2. Drop old table (IRREVERSIBLE)
|
||||
DROP TABLE IF EXISTS entity_versions CASCADE;
|
||||
|
||||
-- 3. Remove deprecated helpers
|
||||
-- Delete src/lib/versioningHelpers.ts
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, rollback steps:
|
||||
|
||||
1. Disable triggers on entity tables
|
||||
2. Revert edge functions to use old JSONB system
|
||||
3. Keep relational tables for future retry
|
||||
|
||||
**Note:** Not recommended - new system is production-ready.
|
||||
538
docs/versioning/MODERATION.md
Normal file
538
docs/versioning/MODERATION.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# Moderation Flow Integration
|
||||
|
||||
**How versioning integrates with the content moderation system**
|
||||
|
||||
## Overview
|
||||
|
||||
The versioning system is tightly integrated with the moderation queue. When moderators approve submissions, versions are automatically created with proper attribution to the original submitter (not the moderator who approved).
|
||||
|
||||
## Submission-to-Version Flow
|
||||
|
||||
### Complete Flow Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI as React UI
|
||||
participant CS as content_submissions
|
||||
participant Edge as Edge Function
|
||||
participant Session as PostgreSQL Session
|
||||
participant Entity as Entity Table
|
||||
participant Trigger as Version Trigger
|
||||
participant Versions as Version Table
|
||||
|
||||
User->>UI: Submit Park Edit
|
||||
UI->>CS: INSERT content_submission
|
||||
Note over CS: status = 'pending'<br/>user_id = submitter
|
||||
|
||||
User->>UI: Moderator Reviews
|
||||
Note over UI: Moderator clicks "Approve"
|
||||
|
||||
UI->>Edge: POST /process-selective-approval
|
||||
Note over Edge: Edge function starts
|
||||
|
||||
Edge->>Session: SET app.current_user_id = submitter_id
|
||||
Edge->>Session: SET app.submission_id = submission_id
|
||||
Note over Session: Session variables set
|
||||
|
||||
Edge->>Entity: UPDATE parks SET name = ...
|
||||
Note over Entity: Entity updated
|
||||
|
||||
Entity->>Trigger: AFTER UPDATE trigger fires
|
||||
Trigger->>Session: Read app.current_user_id
|
||||
Trigger->>Session: Read app.submission_id
|
||||
|
||||
Trigger->>Versions: Mark previous is_current = false
|
||||
Trigger->>Versions: INSERT park_versions
|
||||
Note over Versions: version_number = N+1<br/>created_by = submitter<br/>submission_id = linked
|
||||
|
||||
Trigger-->>Entity: RETURN
|
||||
Entity-->>Edge: Success
|
||||
|
||||
Edge->>CS: UPDATE content_submissions<br/>SET status = 'approved'
|
||||
Edge-->>UI: Return success
|
||||
|
||||
UI->>User: Toast: "Approved! Version 4 created"
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Content Submissions
|
||||
|
||||
User creates submission with their proposed changes:
|
||||
|
||||
```sql
|
||||
INSERT INTO content_submissions (
|
||||
user_id, -- Original submitter
|
||||
submission_type, -- 'park_edit', 'ride_create', etc.
|
||||
content, -- Not used in relational system
|
||||
status -- 'pending'
|
||||
)
|
||||
VALUES (
|
||||
auth.uid(),
|
||||
'park_edit',
|
||||
'{}'::jsonb,
|
||||
'pending'
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Entity Submission Tables
|
||||
|
||||
Detailed submission data stored in entity-specific tables:
|
||||
|
||||
```sql
|
||||
INSERT INTO park_submissions (
|
||||
submission_id, -- FK to content_submissions
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
park_type,
|
||||
-- ... all park fields
|
||||
)
|
||||
VALUES (...);
|
||||
```
|
||||
|
||||
### 3. Edge Function (process-selective-approval)
|
||||
|
||||
Moderator approves submission, edge function orchestrates:
|
||||
|
||||
```typescript
|
||||
// supabase/functions/process-selective-approval/index.ts
|
||||
|
||||
export async function processSelectiveApproval(
|
||||
submissionId: string,
|
||||
selectedItems: string[]
|
||||
) {
|
||||
// Get submission details
|
||||
const { data: submission } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('*, park_submissions(*)')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
// Set session variables for version attribution
|
||||
await supabase.rpc('set_session_variable', {
|
||||
key: 'app.current_user_id',
|
||||
value: submission.user_id, // Original submitter, NOT moderator
|
||||
});
|
||||
|
||||
await supabase.rpc('set_session_variable', {
|
||||
key: 'app.submission_id',
|
||||
value: submissionId,
|
||||
});
|
||||
|
||||
// Update entity (triggers version creation)
|
||||
const { error } = await supabase
|
||||
.from('parks')
|
||||
.update({
|
||||
name: submission.park_submissions.name,
|
||||
description: submission.park_submissions.description,
|
||||
// ... approved fields
|
||||
})
|
||||
.eq('id', submission.park_submissions.park_id);
|
||||
|
||||
// Version is now created automatically via trigger
|
||||
// with created_by = submission.user_id
|
||||
// and submission_id = submissionId
|
||||
|
||||
// Update submission status
|
||||
await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
status: 'approved',
|
||||
reviewed_at: new Date().toISOString(),
|
||||
reviewer_id: moderatorId,
|
||||
})
|
||||
.eq('id', submissionId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Session Variables
|
||||
|
||||
PostgreSQL session variables pass attribution through triggers:
|
||||
|
||||
```sql
|
||||
-- Edge function sets these before entity update
|
||||
SET LOCAL app.current_user_id = 'user-uuid-here';
|
||||
SET LOCAL app.submission_id = 'submission-uuid-here';
|
||||
|
||||
-- Trigger function reads them
|
||||
CREATE FUNCTION create_relational_version() AS $$
|
||||
DECLARE
|
||||
v_created_by UUID;
|
||||
v_submission_id UUID;
|
||||
BEGIN
|
||||
-- Get from session variables
|
||||
v_created_by := NULLIF(current_setting('app.current_user_id', TRUE), '')::UUID;
|
||||
v_submission_id := NULLIF(current_setting('app.submission_id', TRUE), '')::UUID;
|
||||
|
||||
-- Fallback to auth.uid() if not set
|
||||
IF v_created_by IS NULL THEN
|
||||
v_created_by := auth.uid();
|
||||
END IF;
|
||||
|
||||
-- Insert version with attribution
|
||||
INSERT INTO park_versions (..., created_by, submission_id, ...)
|
||||
VALUES (..., v_created_by, v_submission_id, ...);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### 5. Automatic Trigger Execution
|
||||
|
||||
When entity UPDATE occurs, trigger fires automatically:
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER create_park_version_on_change
|
||||
AFTER INSERT OR UPDATE ON parks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.create_relational_version();
|
||||
```
|
||||
|
||||
## Version Attribution
|
||||
|
||||
### Critical: Submitter vs. Moderator
|
||||
|
||||
**IMPORTANT:** Versions are attributed to the **original submitter**, not the moderator who approved.
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - This would attribute to moderator
|
||||
created_by: auth.uid() // Moderator's ID
|
||||
|
||||
// ✅ CORRECT - Attribute to original submitter
|
||||
created_by: submission.user_id // Submitter's ID
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- **Credit** - Users get credit for their contributions
|
||||
- **Audit Trail** - Know who made the change (not just who approved it)
|
||||
- **Reputation** - Contribution counts toward user reputation
|
||||
- **Transparency** - Public can see who contributed what
|
||||
|
||||
### Moderator Tracking
|
||||
|
||||
Moderators are tracked separately in `content_submissions`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
cs.user_id as original_submitter,
|
||||
cs.reviewer_id as moderator_who_approved,
|
||||
pv.created_by as version_attributed_to
|
||||
FROM content_submissions cs
|
||||
JOIN park_versions pv ON pv.submission_id = cs.id
|
||||
WHERE cs.id = 'submission-uuid';
|
||||
|
||||
-- Result:
|
||||
-- original_submitter: user-123 (matches version_attributed_to)
|
||||
-- moderator_who_approved: moderator-456
|
||||
-- version_attributed_to: user-123
|
||||
```
|
||||
|
||||
## Change Types
|
||||
|
||||
Versions are created with appropriate `change_type`:
|
||||
|
||||
### 'created'
|
||||
|
||||
First version when entity is created:
|
||||
|
||||
```sql
|
||||
-- INSERT into parks triggers version
|
||||
INSERT INTO parks (name, slug, ...)
|
||||
VALUES ('New Park', 'new-park', ...);
|
||||
|
||||
-- Trigger creates:
|
||||
-- version_number: 1
|
||||
-- change_type: 'created'
|
||||
-- is_current: true
|
||||
```
|
||||
|
||||
### 'updated'
|
||||
|
||||
Subsequent updates:
|
||||
|
||||
```sql
|
||||
-- UPDATE parks triggers version
|
||||
UPDATE parks
|
||||
SET description = 'Updated description'
|
||||
WHERE id = 'park-uuid';
|
||||
|
||||
-- Trigger creates:
|
||||
-- version_number: N+1
|
||||
-- change_type: 'updated'
|
||||
-- is_current: true
|
||||
-- (Previous version marked is_current = false)
|
||||
```
|
||||
|
||||
### 'restored'
|
||||
|
||||
Rollback to previous version:
|
||||
|
||||
```sql
|
||||
-- Moderator rolls back erroneous approval
|
||||
SELECT rollback_to_version(
|
||||
'park',
|
||||
'park-uuid',
|
||||
'target-version-uuid',
|
||||
auth.uid(),
|
||||
'Incorrect data approved by mistake'
|
||||
);
|
||||
|
||||
-- Creates new version:
|
||||
-- version_number: N+1
|
||||
-- change_type: 'restored'
|
||||
-- is_current: true
|
||||
```
|
||||
|
||||
## Moderation Queue Integration
|
||||
|
||||
### Toast Notifications
|
||||
|
||||
After approval, show version number:
|
||||
|
||||
```typescript
|
||||
// In moderation queue component
|
||||
const handleApprove = async (submissionId: string) => {
|
||||
const result = await processSelectiveApproval(submissionId);
|
||||
|
||||
if (result.success) {
|
||||
// Fetch latest version number
|
||||
const { data: version } = await supabase
|
||||
.from('park_versions')
|
||||
.select('version_number')
|
||||
.eq('submission_id', submissionId)
|
||||
.single();
|
||||
|
||||
toast({
|
||||
title: 'Submission Approved',
|
||||
description: `Version ${version.version_number} created successfully`,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Link to Version History
|
||||
|
||||
```typescript
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Approve and navigate to history
|
||||
handleApprove(submissionId);
|
||||
navigate(`/parks/${parkSlug}?tab=history`);
|
||||
}}
|
||||
>
|
||||
Approve & View History
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Real-Time Updates
|
||||
|
||||
Moderation queue updates when versions are created:
|
||||
|
||||
```typescript
|
||||
// Subscribe to content_submissions changes
|
||||
const subscription = supabase
|
||||
.channel('moderation-queue')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'content_submissions',
|
||||
filter: 'status=eq.approved',
|
||||
},
|
||||
(payload) => {
|
||||
// Refresh queue
|
||||
fetchQueue();
|
||||
|
||||
// Show notification
|
||||
toast({
|
||||
title: 'Submission Approved',
|
||||
description: `${payload.new.submission_type} processed`,
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
```
|
||||
|
||||
## Selective Approval
|
||||
|
||||
When moderators approve only specific fields:
|
||||
|
||||
```typescript
|
||||
// User submits changes to multiple fields
|
||||
const submission = {
|
||||
name: 'Updated Name', // Moderator approves
|
||||
description: 'New Desc', // Moderator approves
|
||||
website_url: 'bad-url.com', // Moderator rejects
|
||||
};
|
||||
|
||||
// Edge function updates only approved fields
|
||||
await supabase
|
||||
.from('parks')
|
||||
.update({
|
||||
name: submission.name, // ✅ Approved
|
||||
description: submission.description, // ✅ Approved
|
||||
// website_url NOT updated ❌ Rejected
|
||||
})
|
||||
.eq('id', parkId);
|
||||
|
||||
// Version created with only approved changes
|
||||
```
|
||||
|
||||
## Rollback After Erroneous Approval
|
||||
|
||||
If moderator approves wrong data:
|
||||
|
||||
```typescript
|
||||
// 1. Moderator realizes mistake
|
||||
// 2. Opens version history
|
||||
// 3. Selects correct previous version
|
||||
// 4. Clicks "Rollback"
|
||||
|
||||
const rollback = await rollbackToVersion(
|
||||
targetVersionId,
|
||||
'Incorrect manufacturer approved, reverting to previous'
|
||||
);
|
||||
|
||||
// New version created:
|
||||
// - change_type: 'restored'
|
||||
// - Data matches target version
|
||||
// - New version_number (not replacing existing version)
|
||||
```
|
||||
|
||||
## Audit Trail
|
||||
|
||||
Complete audit trail from submission to approval to version:
|
||||
|
||||
```sql
|
||||
-- Full audit query
|
||||
SELECT
|
||||
cs.id as submission_id,
|
||||
cs.submitted_at,
|
||||
cs.status,
|
||||
submitter.username as submitted_by,
|
||||
cs.reviewed_at,
|
||||
moderator.username as reviewed_by,
|
||||
pv.version_id,
|
||||
pv.version_number,
|
||||
pv.change_type,
|
||||
pv.created_at as version_created_at
|
||||
FROM content_submissions cs
|
||||
LEFT JOIN profiles submitter ON submitter.user_id = cs.user_id
|
||||
LEFT JOIN profiles moderator ON moderator.user_id = cs.reviewer_id
|
||||
LEFT JOIN park_versions pv ON pv.submission_id = cs.id
|
||||
WHERE cs.id = 'submission-uuid';
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
|
||||
✅ Always set `app.current_user_id` to original submitter
|
||||
✅ Link versions to submissions via `submission_id`
|
||||
✅ Show version numbers in approval toasts
|
||||
✅ Provide rollback for erroneous approvals
|
||||
✅ Track moderator in `content_submissions.reviewer_id`
|
||||
|
||||
### DON'T
|
||||
|
||||
❌ Attribute versions to moderator
|
||||
❌ Skip setting session variables
|
||||
❌ Update entities without proper attribution
|
||||
❌ Delete versions (use rollback instead)
|
||||
❌ Approve without reviewing version impact
|
||||
|
||||
## Testing Moderation Flow
|
||||
|
||||
### Test Script
|
||||
|
||||
```typescript
|
||||
// 1. Create submission as user
|
||||
const { data: submission } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'park_edit',
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
// 2. Create park submission
|
||||
await supabase
|
||||
.from('park_submissions')
|
||||
.insert({
|
||||
submission_id: submission.id,
|
||||
name: 'Test Park',
|
||||
slug: 'test-park',
|
||||
park_type: 'theme_park',
|
||||
});
|
||||
|
||||
// 3. Approve as moderator
|
||||
const result = await processSelectiveApproval(submission.id);
|
||||
|
||||
// 4. Verify version created
|
||||
const { data: version } = await supabase
|
||||
.from('park_versions')
|
||||
.select('*')
|
||||
.eq('submission_id', submission.id)
|
||||
.single();
|
||||
|
||||
expect(version.created_by).toBe(userId); // NOT moderatorId
|
||||
expect(version.submission_id).toBe(submission.id);
|
||||
expect(version.version_number).toBe(1);
|
||||
expect(version.change_type).toBe('created');
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Session Variable Not Set
|
||||
|
||||
```typescript
|
||||
// Edge function MUST set session variables
|
||||
try {
|
||||
await supabase.rpc('set_session_variable', {
|
||||
key: 'app.current_user_id',
|
||||
value: submission.user_id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to set session variable:', error);
|
||||
// Version will fallback to auth.uid() (moderator)
|
||||
// This is WRONG but prevents complete failure
|
||||
}
|
||||
```
|
||||
|
||||
### Version Creation Fails
|
||||
|
||||
```typescript
|
||||
// Entity update succeeds but version creation fails
|
||||
// Transaction rollback would be ideal but triggers don't support it
|
||||
|
||||
// Workaround: Check version was created
|
||||
const { data: version } = await supabase
|
||||
.from('park_versions')
|
||||
.select('version_id')
|
||||
.eq('park_id', parkId)
|
||||
.eq('is_current', true)
|
||||
.single();
|
||||
|
||||
if (!version) {
|
||||
console.error('Version creation failed');
|
||||
// Log to admin audit log
|
||||
await supabase.rpc('log_admin_action', {
|
||||
_admin_user_id: moderatorId,
|
||||
_action: 'version_creation_failure',
|
||||
_details: { park_id: parkId, submission_id: submissionId },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md#moderation-issues) for common moderation-related issues.
|
||||
150
docs/versioning/README.md
Normal file
150
docs/versioning/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Universal Versioning System
|
||||
|
||||
**Complete documentation for the relational versioning system**
|
||||
|
||||
## Overview
|
||||
|
||||
The Universal Versioning System automatically tracks all changes to entities (parks, rides, companies, ride models) using a pure relational database structure. Every INSERT or UPDATE creates a timestamped version with full attribution and audit trail.
|
||||
|
||||
### Key Features
|
||||
|
||||
✅ **Automatic Version Creation** - Triggers handle versioning transparently
|
||||
✅ **Pure Relational Structure** - No JSONB, fully queryable and type-safe
|
||||
✅ **Full Audit Trail** - User, timestamp, and submission tracking
|
||||
✅ **Version Comparison** - Visual diff between any two versions
|
||||
✅ **Rollback Support** - Restore to any previous version
|
||||
✅ **Moderation Integration** - Links versions to content submissions
|
||||
|
||||
### Why Relational Versioning?
|
||||
|
||||
| Benefit | Description |
|
||||
|---------|-------------|
|
||||
| **Queryable** | Filter and search across version fields efficiently with SQL |
|
||||
| **Type-safe** | Foreign keys enforce referential integrity |
|
||||
| **Performant** | Indexed columns enable fast queries |
|
||||
| **Secure** | Row-Level Security at column level |
|
||||
| **Maintainable** | Standard SQL operations, no JSONB parsing |
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
### Core Documentation
|
||||
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** - System design and data flow
|
||||
- **[SCHEMA.md](./SCHEMA.md)** - Database tables, triggers, and functions
|
||||
- **[FRONTEND.md](./FRONTEND.md)** - React hooks and components
|
||||
|
||||
### Integration Guides
|
||||
- **[MODERATION.md](./MODERATION.md)** - How versioning integrates with moderation flow
|
||||
- **[MIGRATION.md](./MIGRATION.md)** - Migrating from old JSONB system
|
||||
|
||||
### Reference
|
||||
- **[API.md](./API.md)** - Complete API reference for all functions and hooks
|
||||
- **[BEST_PRACTICES.md](./BEST_PRACTICES.md)** - Guidelines and recommendations
|
||||
- **[TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** - Common issues and solutions
|
||||
|
||||
## Quick Start
|
||||
|
||||
### For Developers
|
||||
|
||||
```typescript
|
||||
// Add version indicator to entity page
|
||||
import { VersionIndicator } from '@/components/versioning/VersionIndicator';
|
||||
|
||||
<VersionIndicator
|
||||
entityType="park"
|
||||
entityId={park.id}
|
||||
entityName={park.name}
|
||||
/>
|
||||
```
|
||||
|
||||
### For Database Admins
|
||||
|
||||
```sql
|
||||
-- Get all versions of a specific park
|
||||
SELECT * FROM park_versions
|
||||
WHERE park_id = 'uuid-here'
|
||||
ORDER BY version_number DESC;
|
||||
|
||||
-- Compare two versions
|
||||
SELECT * FROM get_version_diff('park', 'version-1-uuid', 'version-2-uuid');
|
||||
```
|
||||
|
||||
### For Moderators
|
||||
|
||||
When approving submissions, versions are automatically created with:
|
||||
- Attribution to original submitter (not moderator)
|
||||
- Link to content submission
|
||||
- Full change tracking
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[User Submits Edit] --> B[Content Submission]
|
||||
B --> C[Moderator Approves]
|
||||
C --> D[Edge Function]
|
||||
D --> E[Set Session Variables]
|
||||
E --> F[UPDATE Entity Table]
|
||||
F --> G[Trigger Fires]
|
||||
G --> H[create_relational_version]
|
||||
H --> I[INSERT Version Table]
|
||||
I --> J[Version Created]
|
||||
```
|
||||
|
||||
## Supported Entities
|
||||
|
||||
| Entity Type | Version Table | Main Table |
|
||||
|-------------|---------------|------------|
|
||||
| `park` | `park_versions` | `parks` |
|
||||
| `ride` | `ride_versions` | `rides` |
|
||||
| `company` | `company_versions` | `companies` |
|
||||
| `ride_model` | `ride_model_versions` | `ride_models` |
|
||||
|
||||
## Version Lifecycle
|
||||
|
||||
1. **Creation** - Entity INSERT triggers first version (version_number: 1)
|
||||
2. **Updates** - Each UPDATE creates new version, increments version_number
|
||||
3. **Current Flag** - Only latest version has `is_current = true`
|
||||
4. **Retention** - Old versions retained (configurable cleanup)
|
||||
5. **Rollback** - Any version can be restored, creates new version
|
||||
|
||||
## Security Model
|
||||
|
||||
### Row-Level Security Policies
|
||||
|
||||
- **Public** - Can view current versions only (`is_current = true`)
|
||||
- **Moderators** - Can view all versions
|
||||
- **Users** - Can view versions they created
|
||||
- **System** - Can create versions via triggers
|
||||
|
||||
### Session Variables
|
||||
|
||||
The system uses PostgreSQL session variables for attribution:
|
||||
|
||||
- `app.current_user_id` - Original submitter (not moderator)
|
||||
- `app.submission_id` - Link to content_submissions record
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Indexes** - All version tables have indexes on `entity_id`, `created_at`, `version_number`
|
||||
- **Cleanup** - Use `cleanup_old_versions()` to retain only N recent versions
|
||||
- **Queries** - Version queries are fast due to proper indexing
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for system design details
|
||||
2. Review [SCHEMA.md](./SCHEMA.md) for database structure
|
||||
3. Check [FRONTEND.md](./FRONTEND.md) for React integration
|
||||
4. See [API.md](./API.md) for complete function reference
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) first
|
||||
- Review [BEST_PRACTICES.md](./BEST_PRACTICES.md) for guidelines
|
||||
- Consult main project documentation
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Production Ready
|
||||
**Last Updated:** 2025-10-15
|
||||
**Version:** 1.0.0
|
||||
576
docs/versioning/SCHEMA.md
Normal file
576
docs/versioning/SCHEMA.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# Database Schema Documentation
|
||||
|
||||
**Complete reference for version tables, triggers, functions, and policies**
|
||||
|
||||
## Version Tables
|
||||
|
||||
Each entity type has a corresponding version table that mirrors the original table structure with additional version metadata.
|
||||
|
||||
### Common Version Metadata
|
||||
|
||||
All version tables include these standard columns:
|
||||
|
||||
```sql
|
||||
version_id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
||||
{entity}_id UUID NOT NULL -- FK to original entity
|
||||
version_number INTEGER NOT NULL
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
created_by UUID -- FK to profiles (original submitter)
|
||||
change_type version_change_type NOT NULL DEFAULT 'updated'
|
||||
change_reason TEXT
|
||||
submission_id UUID -- FK to content_submissions
|
||||
is_current BOOLEAN NOT NULL DEFAULT true
|
||||
```
|
||||
|
||||
### Enum: version_change_type
|
||||
|
||||
```sql
|
||||
CREATE TYPE version_change_type AS ENUM (
|
||||
'created',
|
||||
'updated',
|
||||
'deleted',
|
||||
'restored',
|
||||
'archived'
|
||||
);
|
||||
```
|
||||
|
||||
## Table Structure
|
||||
|
||||
### park_versions
|
||||
|
||||
Tracks all changes to parks.
|
||||
|
||||
```sql
|
||||
CREATE TABLE park_versions (
|
||||
-- Version metadata
|
||||
version_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
park_id UUID NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
change_type version_change_type NOT NULL DEFAULT 'updated',
|
||||
change_reason TEXT,
|
||||
submission_id UUID,
|
||||
is_current BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Park data (mirrors parks table)
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT,
|
||||
park_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
opening_date DATE,
|
||||
opening_date_precision TEXT,
|
||||
closing_date DATE,
|
||||
closing_date_precision TEXT,
|
||||
location_id UUID,
|
||||
operator_id UUID,
|
||||
property_owner_id UUID,
|
||||
website_url TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
banner_image_url TEXT,
|
||||
banner_image_id TEXT,
|
||||
card_image_url TEXT,
|
||||
card_image_id TEXT
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_park_versions_park_id ON park_versions(park_id);
|
||||
CREATE INDEX idx_park_versions_created_at ON park_versions(created_at DESC);
|
||||
CREATE INDEX idx_park_versions_is_current ON park_versions(is_current);
|
||||
CREATE INDEX idx_park_versions_created_by ON park_versions(created_by);
|
||||
CREATE INDEX idx_park_versions_submission_id ON park_versions(submission_id);
|
||||
```
|
||||
|
||||
### ride_versions
|
||||
|
||||
Tracks all changes to rides.
|
||||
|
||||
```sql
|
||||
CREATE TABLE ride_versions (
|
||||
-- Version metadata
|
||||
version_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ride_id UUID NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
change_type version_change_type NOT NULL DEFAULT 'updated',
|
||||
change_reason TEXT,
|
||||
submission_id UUID,
|
||||
is_current BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Ride data (mirrors rides table)
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT,
|
||||
ride_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
opening_date DATE,
|
||||
opening_date_precision TEXT,
|
||||
closing_date DATE,
|
||||
closing_date_precision TEXT,
|
||||
park_id UUID,
|
||||
manufacturer_id UUID,
|
||||
designer_id UUID,
|
||||
model_id UUID,
|
||||
|
||||
-- Technical specifications
|
||||
height_m NUMERIC,
|
||||
speed_kmh NUMERIC,
|
||||
length_m NUMERIC,
|
||||
duration_seconds INTEGER,
|
||||
inversions_count INTEGER,
|
||||
max_vertical_angle NUMERIC,
|
||||
g_force_max NUMERIC,
|
||||
|
||||
-- Images
|
||||
banner_image_url TEXT,
|
||||
banner_image_id TEXT,
|
||||
card_image_url TEXT,
|
||||
card_image_id TEXT
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_ride_versions_ride_id ON ride_versions(ride_id);
|
||||
CREATE INDEX idx_ride_versions_created_at ON ride_versions(created_at DESC);
|
||||
CREATE INDEX idx_ride_versions_is_current ON ride_versions(is_current);
|
||||
CREATE INDEX idx_ride_versions_created_by ON ride_versions(created_by);
|
||||
CREATE INDEX idx_ride_versions_submission_id ON ride_versions(submission_id);
|
||||
```
|
||||
|
||||
### company_versions
|
||||
|
||||
Tracks all changes to companies (manufacturers, operators, designers, property owners).
|
||||
|
||||
```sql
|
||||
CREATE TABLE company_versions (
|
||||
-- Version metadata
|
||||
version_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id UUID NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
change_type version_change_type NOT NULL DEFAULT 'updated',
|
||||
change_reason TEXT,
|
||||
submission_id UUID,
|
||||
is_current BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Company data (mirrors companies table)
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT,
|
||||
company_type TEXT NOT NULL, -- manufacturer, operator, designer, property_owner
|
||||
person_type TEXT DEFAULT 'company',
|
||||
founded_date DATE,
|
||||
founded_date_precision TEXT,
|
||||
founded_year INTEGER,
|
||||
headquarters_location TEXT,
|
||||
website_url TEXT,
|
||||
logo_url TEXT,
|
||||
banner_image_url TEXT,
|
||||
banner_image_id TEXT,
|
||||
card_image_url TEXT,
|
||||
card_image_id TEXT
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_company_versions_company_id ON company_versions(company_id);
|
||||
CREATE INDEX idx_company_versions_created_at ON company_versions(created_at DESC);
|
||||
CREATE INDEX idx_company_versions_is_current ON company_versions(is_current);
|
||||
CREATE INDEX idx_company_versions_created_by ON company_versions(created_by);
|
||||
CREATE INDEX idx_company_versions_submission_id ON company_versions(submission_id);
|
||||
```
|
||||
|
||||
### ride_model_versions
|
||||
|
||||
Tracks all changes to ride models.
|
||||
|
||||
```sql
|
||||
CREATE TABLE ride_model_versions (
|
||||
-- Version metadata
|
||||
version_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ride_model_id UUID NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
change_type version_change_type NOT NULL DEFAULT 'updated',
|
||||
change_reason TEXT,
|
||||
submission_id UUID,
|
||||
is_current BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Ride model data (mirrors ride_models table)
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT,
|
||||
manufacturer_id UUID,
|
||||
model_type TEXT NOT NULL,
|
||||
introduced_year INTEGER,
|
||||
retired_year INTEGER,
|
||||
|
||||
-- Images
|
||||
banner_image_url TEXT,
|
||||
banner_image_id TEXT,
|
||||
card_image_url TEXT,
|
||||
card_image_id TEXT
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_ride_model_versions_ride_model_id ON ride_model_versions(ride_model_id);
|
||||
CREATE INDEX idx_ride_model_versions_created_at ON ride_model_versions(created_at DESC);
|
||||
CREATE INDEX idx_ride_model_versions_is_current ON ride_model_versions(is_current);
|
||||
CREATE INDEX idx_ride_model_versions_created_by ON ride_model_versions(created_by);
|
||||
CREATE INDEX idx_ride_model_versions_submission_id ON ride_model_versions(submission_id);
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
### Automatic Version Creation
|
||||
|
||||
Each entity table has an `AFTER INSERT OR UPDATE` trigger that calls the `create_relational_version()` function.
|
||||
|
||||
```sql
|
||||
-- Example: Park versioning trigger
|
||||
CREATE TRIGGER create_park_version_on_change
|
||||
AFTER INSERT OR UPDATE ON parks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.create_relational_version();
|
||||
```
|
||||
|
||||
Triggers exist for:
|
||||
- `parks` → `create_park_version_on_change`
|
||||
- `rides` → `create_ride_version_on_change`
|
||||
- `companies` → `create_company_version_on_change`
|
||||
- `ride_models` → `create_ride_model_version_on_change`
|
||||
|
||||
## Functions
|
||||
|
||||
### create_relational_version()
|
||||
|
||||
**Purpose:** Core trigger function that creates version records automatically.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.create_relational_version()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_version_table TEXT;
|
||||
v_entity_id_col TEXT;
|
||||
v_created_by UUID;
|
||||
v_submission_id UUID;
|
||||
v_change_type version_change_type;
|
||||
v_version_number INTEGER;
|
||||
BEGIN
|
||||
-- Determine version table based on trigger source
|
||||
CASE TG_TABLE_NAME
|
||||
WHEN 'parks' THEN
|
||||
v_version_table := 'park_versions';
|
||||
v_entity_id_col := 'park_id';
|
||||
WHEN 'rides' THEN
|
||||
v_version_table := 'ride_versions';
|
||||
v_entity_id_col := 'ride_id';
|
||||
WHEN 'companies' THEN
|
||||
v_version_table := 'company_versions';
|
||||
v_entity_id_col := 'company_id';
|
||||
WHEN 'ride_models' THEN
|
||||
v_version_table := 'ride_model_versions';
|
||||
v_entity_id_col := 'ride_model_id';
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unsupported table: %', TG_TABLE_NAME;
|
||||
END CASE;
|
||||
|
||||
-- Get user and submission from session variables
|
||||
v_created_by := NULLIF(current_setting('app.current_user_id', TRUE), '')::UUID;
|
||||
v_submission_id := NULLIF(current_setting('app.submission_id', TRUE), '')::UUID;
|
||||
|
||||
-- Fallback to auth.uid() if no session variable
|
||||
IF v_created_by IS NULL THEN
|
||||
v_created_by := auth.uid();
|
||||
END IF;
|
||||
|
||||
-- Determine change type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_change_type := 'created';
|
||||
v_version_number := 1;
|
||||
ELSE
|
||||
v_change_type := 'updated';
|
||||
|
||||
-- Mark previous current version as not current
|
||||
EXECUTE format('UPDATE %I SET is_current = false WHERE %I = $1 AND is_current = true',
|
||||
v_version_table, v_entity_id_col)
|
||||
USING NEW.id;
|
||||
|
||||
-- Get next version number
|
||||
EXECUTE format('SELECT COALESCE(MAX(version_number), 0) + 1 FROM %I WHERE %I = $1',
|
||||
v_version_table, v_entity_id_col)
|
||||
INTO v_version_number
|
||||
USING NEW.id;
|
||||
END IF;
|
||||
|
||||
-- Insert new version (dynamic SQL with all entity fields)
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I SELECT $1, $2, $3, now(), $4, $5, NULL, $6, true, (SELECT %I.* FROM %I WHERE id = $2)',
|
||||
v_version_table, TG_TABLE_NAME, TG_TABLE_NAME
|
||||
)
|
||||
USING gen_random_uuid(), NEW.id, v_version_number, v_created_by, v_change_type, v_submission_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### get_version_diff()
|
||||
|
||||
**Purpose:** Compare two versions and return field-level differences.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.get_version_diff(
|
||||
p_entity_type TEXT,
|
||||
p_from_version_id UUID,
|
||||
p_to_version_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_version_table TEXT;
|
||||
v_from_record JSONB;
|
||||
v_to_record JSONB;
|
||||
v_diff JSONB := '{}'::JSONB;
|
||||
v_key TEXT;
|
||||
v_from_value JSONB;
|
||||
v_to_value JSONB;
|
||||
BEGIN
|
||||
-- Determine version table
|
||||
v_version_table := p_entity_type || '_versions';
|
||||
|
||||
-- Get both versions as JSONB
|
||||
EXECUTE format('SELECT row_to_json(t)::JSONB FROM %I t WHERE version_id = $1', v_version_table)
|
||||
INTO v_from_record
|
||||
USING p_from_version_id;
|
||||
|
||||
EXECUTE format('SELECT row_to_json(t)::JSONB FROM %I t WHERE version_id = $1', v_version_table)
|
||||
INTO v_to_record
|
||||
USING p_to_version_id;
|
||||
|
||||
-- Compare each key
|
||||
FOR v_key IN SELECT jsonb_object_keys(v_to_record)
|
||||
LOOP
|
||||
-- Skip metadata fields
|
||||
CONTINUE WHEN v_key IN ('version_id', 'version_number', 'created_at', 'created_by', 'is_current');
|
||||
|
||||
v_from_value := v_from_record -> v_key;
|
||||
v_to_value := v_to_record -> v_key;
|
||||
|
||||
-- If values differ, add to diff
|
||||
IF v_from_value IS DISTINCT FROM v_to_value THEN
|
||||
v_diff := v_diff || jsonb_build_object(
|
||||
v_key, jsonb_build_object(
|
||||
'from', v_from_value,
|
||||
'to', v_to_value,
|
||||
'changed', true
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_diff;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### cleanup_old_versions()
|
||||
|
||||
**Purpose:** Remove old versions, keeping only N most recent per entity.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_old_versions(
|
||||
p_entity_type TEXT,
|
||||
p_keep_versions INTEGER DEFAULT 50
|
||||
)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_version_table TEXT;
|
||||
v_entity_id_col TEXT;
|
||||
v_deleted_count INTEGER;
|
||||
BEGIN
|
||||
-- Determine table names
|
||||
v_version_table := p_entity_type || '_versions';
|
||||
v_entity_id_col := p_entity_type || '_id';
|
||||
|
||||
-- Delete old versions, keeping p_keep_versions most recent per entity
|
||||
EXECUTE format('
|
||||
DELETE FROM %I
|
||||
WHERE version_id IN (
|
||||
SELECT version_id FROM (
|
||||
SELECT
|
||||
version_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY %I ORDER BY version_number DESC) as rn
|
||||
FROM %I
|
||||
) sub
|
||||
WHERE rn > $1
|
||||
)',
|
||||
v_version_table, v_entity_id_col, v_version_table
|
||||
)
|
||||
USING p_keep_versions;
|
||||
|
||||
GET DIAGNOSTICS v_deleted_count = ROW_COUNT;
|
||||
|
||||
RETURN v_deleted_count;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
### rollback_to_version()
|
||||
|
||||
**Purpose:** Restore entity to a previous version.
|
||||
|
||||
```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_version_table TEXT;
|
||||
v_entity_table TEXT;
|
||||
v_new_version_id UUID;
|
||||
BEGIN
|
||||
v_version_table := p_entity_type || '_versions';
|
||||
v_entity_table := p_entity_type || 's'; -- Pluralize
|
||||
|
||||
-- Set session variables for attribution
|
||||
PERFORM set_config('app.current_user_id', p_changed_by::TEXT, true);
|
||||
PERFORM set_config('app.change_reason', p_reason, true);
|
||||
|
||||
-- Update entity table with data from target version
|
||||
EXECUTE format('
|
||||
UPDATE %I
|
||||
SET ... (all fields from version)
|
||||
FROM %I v
|
||||
WHERE v.version_id = $1 AND %I.id = $2',
|
||||
v_entity_table, v_version_table, v_entity_table
|
||||
)
|
||||
USING p_target_version_id, p_entity_id;
|
||||
|
||||
-- Trigger will create new version with change_type='restored'
|
||||
|
||||
RETURN v_new_version_id;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
## Row-Level Security (RLS)
|
||||
|
||||
All version tables have RLS enabled with these policies:
|
||||
|
||||
### Public Access
|
||||
|
||||
```sql
|
||||
CREATE POLICY "Public can view current versions"
|
||||
ON park_versions
|
||||
FOR SELECT
|
||||
USING (is_current = true);
|
||||
```
|
||||
|
||||
### Moderator Access
|
||||
|
||||
```sql
|
||||
CREATE POLICY "Moderators can view all versions"
|
||||
ON park_versions
|
||||
FOR SELECT
|
||||
USING (is_moderator(auth.uid()));
|
||||
```
|
||||
|
||||
### User Access
|
||||
|
||||
```sql
|
||||
CREATE POLICY "Users can view their own versions"
|
||||
ON park_versions
|
||||
FOR SELECT
|
||||
USING (created_by = auth.uid());
|
||||
```
|
||||
|
||||
### System Access
|
||||
|
||||
```sql
|
||||
-- Triggers can insert (SECURITY DEFINER)
|
||||
-- No UPDATE or DELETE policies (only system can modify)
|
||||
```
|
||||
|
||||
## Example Queries
|
||||
|
||||
### Get Version History
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
version_number,
|
||||
created_at,
|
||||
change_type,
|
||||
p.username as changed_by_username,
|
||||
name,
|
||||
description
|
||||
FROM park_versions pv
|
||||
LEFT JOIN profiles p ON p.user_id = pv.created_by
|
||||
WHERE park_id = 'uuid-here'
|
||||
ORDER BY version_number DESC;
|
||||
```
|
||||
|
||||
### Compare Versions
|
||||
|
||||
```sql
|
||||
SELECT * FROM get_version_diff(
|
||||
'park',
|
||||
'older-version-uuid',
|
||||
'newer-version-uuid'
|
||||
);
|
||||
```
|
||||
|
||||
### Find Recent Changes
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
pv.version_number,
|
||||
pv.created_at,
|
||||
p.username,
|
||||
parks.name as park_name
|
||||
FROM park_versions pv
|
||||
JOIN parks ON parks.id = pv.park_id
|
||||
LEFT JOIN profiles p ON p.user_id = pv.created_by
|
||||
WHERE pv.created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY pv.created_at DESC;
|
||||
```
|
||||
|
||||
### Cleanup Old Versions
|
||||
|
||||
```sql
|
||||
-- Keep only 50 most recent versions per park
|
||||
SELECT cleanup_old_versions('park', 50);
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Indexes** ensure fast lookups by entity_id, created_at, and is_current
|
||||
- **Partitioning** could be added for very large version tables
|
||||
- **Archival** old versions can be moved to cold storage
|
||||
- **Cleanup** should run monthly to prevent unbounded growth
|
||||
|
||||
## Migration Notes
|
||||
|
||||
The old `entity_versions` JSONB table is deprecated but retained for backward compatibility. New versions are written only to relational tables. See [MIGRATION.md](./MIGRATION.md) for migration details.
|
||||
67
docs/versioning/TROUBLESHOOTING.md
Normal file
67
docs/versioning/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
## Versions Not Being Created
|
||||
|
||||
**Symptoms:** Entity updates don't create versions
|
||||
|
||||
**Solutions:**
|
||||
1. Check if triggers are enabled: `SELECT * FROM pg_trigger WHERE tgname LIKE '%version%';`
|
||||
2. Verify session variables are set in edge function
|
||||
3. Check trigger function logs in Supabase dashboard
|
||||
4. Ensure entity table has trigger attached
|
||||
|
||||
## "Cannot read versions" Error
|
||||
|
||||
**Symptoms:** Frontend can't fetch versions
|
||||
|
||||
**Solutions:**
|
||||
1. Check RLS policies on version tables
|
||||
2. Verify user authentication (is `auth.uid()` valid?)
|
||||
3. Check entity exists
|
||||
4. Inspect browser console for specific error
|
||||
|
||||
## Version History Not Showing in UI
|
||||
|
||||
**Symptoms:** Component renders but no versions display
|
||||
|
||||
**Solutions:**
|
||||
1. Verify `useEntityVersions` hook is called correctly
|
||||
2. Check entity ID is correct
|
||||
3. Inspect network tab for failed queries
|
||||
4. Check if versions exist in database
|
||||
|
||||
## Rollback Fails
|
||||
|
||||
**Symptoms:** "Permission denied" or rollback doesn't work
|
||||
|
||||
**Solutions:**
|
||||
1. Verify user has moderator role: `SELECT has_role(auth.uid(), 'moderator')`
|
||||
2. Check target version exists
|
||||
3. Ensure entity hasn't been deleted
|
||||
4. Check MFA requirement (`has_aal2()`)
|
||||
|
||||
## Attribution Issues
|
||||
|
||||
**Symptoms:** Versions attributed to moderator instead of submitter
|
||||
|
||||
**Solutions:**
|
||||
1. Verify edge function sets `app.current_user_id` to `submission.user_id`
|
||||
2. Check session variables before entity update
|
||||
3. Review trigger function logic
|
||||
|
||||
## Performance Issues
|
||||
|
||||
**Symptoms:** Slow version queries
|
||||
|
||||
**Solutions:**
|
||||
1. Check indexes exist on version tables
|
||||
2. Run `cleanup_old_versions()` to reduce table size
|
||||
3. Implement pagination for large version lists
|
||||
4. Use `is_current = true` filter when only latest version needed
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Review [ARCHITECTURE.md](./ARCHITECTURE.md) for system design
|
||||
- Check [SCHEMA.md](./SCHEMA.md) for database structure
|
||||
- See [FRONTEND.md](./FRONTEND.md) for React integration
|
||||
- Consult [MODERATION.md](./MODERATION.md) for workflow issues
|
||||
Reference in New Issue
Block a user