mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11: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