feat: Implement versioning documentation

This commit is contained in:
gpt-engineer-app[bot]
2025-10-15 17:54:53 +00:00
parent 96a5d235e9
commit ea78aff4a7
9 changed files with 2183 additions and 0 deletions

34
docs/versioning/API.md Normal file
View 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.

View 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

View 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
View 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.

View 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.

View 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
View 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
View 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.

View 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