diff --git a/docs/versioning/API.md b/docs/versioning/API.md new file mode 100644 index 00000000..c1b1018a --- /dev/null +++ b/docs/versioning/API.md @@ -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 + +- `` - Version badge +- `` - Full timeline +- `` - Side-by-side diff +- `` - Restore confirmation + +See [FRONTEND.md](./FRONTEND.md) for detailed usage. diff --git a/docs/versioning/ARCHITECTURE.md b/docs/versioning/ARCHITECTURE.md new file mode 100644 index 00000000..1d03dea4 --- /dev/null +++ b/docs/versioning/ARCHITECTURE.md @@ -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 diff --git a/docs/versioning/BEST_PRACTICES.md b/docs/versioning/BEST_PRACTICES.md new file mode 100644 index 00000000..f336a4e2 --- /dev/null +++ b/docs/versioning/BEST_PRACTICES.md @@ -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 +}); +``` diff --git a/docs/versioning/FRONTEND.md b/docs/versioning/FRONTEND.md new file mode 100644 index 00000000..d044a408 --- /dev/null +++ b/docs/versioning/FRONTEND.md @@ -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; + fetchFieldHistory: (versionId: string) => Promise; + compareVersions: (fromId: string, toId: string) => Promise; + rollbackToVersion: (versionId: string, reason: string) => Promise; +} +``` + +#### Methods + +**fetchVersions()** +- Fetches all versions for the entity +- Automatically called on mount and entity ID change +- Returns: `Promise` + +**fetchFieldHistory(versionId)** +- Fetches detailed field changes for a specific version +- Populates `fieldHistory` state +- Parameters: + - `versionId: string` - Version to analyze +- Returns: `Promise` + +**compareVersions(fromId, toId)** +- Compares two versions using database function +- Parameters: + - `fromId: string` - Older version ID + - `toId: string` - Newer version ID +- Returns: `Promise` + +**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` - 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 ; + if (error) return ; + + return ( +
+ {Object.entries(diff || {}).map(([field, change]) => ( + + ))} +
+ ); +}; +``` + +#### 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) + + +// Compact mode (badge only) + +``` + +#### 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'; + + + + + + +``` + +#### 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'; + + +``` + +#### 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; +} +``` + +#### Usage + +```typescript +import { RollbackDialog } from '@/components/versioning/RollbackDialog'; + + +``` + +#### 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 ( +
+ {/* Header with version indicator */} +
+

{park.name}

+ +
+ + {/* Tabs with history */} + + + Details + History + + + + {/* Entity details */} + + + + + + +
+ ); +}; +``` + +### Custom Version List + +```typescript +const CustomVersionList = ({ parkId }: { parkId: string }) => { + const { versions, loading } = useEntityVersions('park', parkId); + + if (loading) return ; + + return ( +
    + {versions.map((version) => ( +
  • + {version.change_type} + Version {version.version_number} + {formatDate(version.created_at)} + +
  • + ))} +
+ ); +}; +``` + +### 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 ; + + const changedFields = Object.entries(diff || {}).filter( + ([_, change]) => change.changed + ); + + return ( + + + {changedFields.length} fields changed + + + {changedFields.map(([field, change]) => ( +
+
+ +
{JSON.stringify(change.from, null, 2)}
+
+
+ +
{JSON.stringify(change.to, null, 2)}
+
+
+ ))} +
+
+ ); +}; +``` + +## 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 ; + +// Error state (versions will be empty array) +if (versions.length === 0) { + return ; +} + +// Success state +return ; +``` + +## 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(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. diff --git a/docs/versioning/MIGRATION.md b/docs/versioning/MIGRATION.md new file mode 100644 index 00000000..16c223e0 --- /dev/null +++ b/docs/versioning/MIGRATION.md @@ -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. diff --git a/docs/versioning/MODERATION.md b/docs/versioning/MODERATION.md new file mode 100644 index 00000000..47da8361 --- /dev/null +++ b/docs/versioning/MODERATION.md @@ -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'
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
created_by = submitter
submission_id = linked + + Trigger-->>Entity: RETURN + Entity-->>Edge: Success + + Edge->>CS: UPDATE content_submissions
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 + +``` + +### 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. diff --git a/docs/versioning/README.md b/docs/versioning/README.md new file mode 100644 index 00000000..8314a49b --- /dev/null +++ b/docs/versioning/README.md @@ -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'; + + +``` + +### 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 diff --git a/docs/versioning/SCHEMA.md b/docs/versioning/SCHEMA.md new file mode 100644 index 00000000..9813d649 --- /dev/null +++ b/docs/versioning/SCHEMA.md @@ -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. diff --git a/docs/versioning/TROUBLESHOOTING.md b/docs/versioning/TROUBLESHOOTING.md new file mode 100644 index 00000000..67705c04 --- /dev/null +++ b/docs/versioning/TROUBLESHOOTING.md @@ -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