diff --git a/src/App.tsx b/src/App.tsx index d3931acd..558100ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ const AdminSystemLog = lazy(() => import("./pages/AdminSystemLog")); const AdminUsers = lazy(() => import("./pages/AdminUsers")); const AdminBlog = lazy(() => import("./pages/AdminBlog")); const AdminSettings = lazy(() => import("./pages/AdminSettings")); +const AdminDatabaseStats = lazy(() => import("./pages/AdminDatabaseStats")); const AdminContact = lazy(() => import("./pages/admin/AdminContact")); const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings")); const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring")); @@ -414,6 +415,14 @@ function AppContent(): React.JSX.Element { } /> + + + + } + /> {/* Utility routes - lazy loaded */} } /> diff --git a/src/components/admin/database-stats/DatabaseStatsCard.tsx b/src/components/admin/database-stats/DatabaseStatsCard.tsx new file mode 100644 index 00000000..369751f1 --- /dev/null +++ b/src/components/admin/database-stats/DatabaseStatsCard.tsx @@ -0,0 +1,45 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface DatabaseStatsCardProps { + title: string; + icon: LucideIcon; + stats: Array<{ + label: string; + value: number | string; + trend?: { + value: number; + period: string; + }; + }>; + iconClassName?: string; +} + +export function DatabaseStatsCard({ title, icon: Icon, stats, iconClassName }: DatabaseStatsCardProps) { + return ( + + + {title} + + + +
+ {stats.map((stat, index) => ( +
+ {stat.label} +
+ {stat.value.toLocaleString()} + {stat.trend && ( + + +{stat.trend.value} ({stat.trend.period}) + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/admin/database-stats/RecentAdditionsTable.tsx b/src/components/admin/database-stats/RecentAdditionsTable.tsx new file mode 100644 index 00000000..71690d3d --- /dev/null +++ b/src/components/admin/database-stats/RecentAdditionsTable.tsx @@ -0,0 +1,221 @@ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { formatDistanceToNow } from 'date-fns'; +import { + Building2, + Bike, + Factory, + Box, + MapPin, + Calendar, + Image, + Download, + Search +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import type { RecentAddition } from '@/types/database-stats'; + +interface RecentAdditionsTableProps { + additions: RecentAddition[]; + isLoading: boolean; +} + +const entityTypeConfig = { + park: { icon: Building2, label: 'Park', color: 'bg-blue-500' }, + ride: { icon: Bike, label: 'Ride', color: 'bg-purple-500' }, + company: { icon: Factory, label: 'Company', color: 'bg-orange-500' }, + ride_model: { icon: Box, label: 'Model', color: 'bg-green-500' }, + location: { icon: MapPin, label: 'Location', color: 'bg-yellow-500' }, + timeline_event: { icon: Calendar, label: 'Event', color: 'bg-pink-500' }, + photo: { icon: Image, label: 'Photo', color: 'bg-teal-500' }, +}; + +export function RecentAdditionsTable({ additions, isLoading }: RecentAdditionsTableProps) { + const [entityTypeFilter, setEntityTypeFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredAdditions = useMemo(() => { + let filtered = additions; + + if (entityTypeFilter !== 'all') { + filtered = filtered.filter(item => item.entity_type === entityTypeFilter); + } + + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(item => + item.entity_name.toLowerCase().includes(query) || + item.created_by_username?.toLowerCase().includes(query) + ); + } + + return filtered; + }, [additions, entityTypeFilter, searchQuery]); + + const exportToCSV = () => { + const headers = ['Type', 'Name', 'Added By', 'Added At']; + const rows = filteredAdditions.map(item => [ + entityTypeConfig[item.entity_type].label, + item.entity_name, + item.created_by_username || 'System', + new Date(item.created_at).toISOString(), + ]); + + const csv = [headers, ...rows].map(row => row.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `recent-additions-${new Date().toISOString()}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + const getEntityLink = (item: RecentAddition) => { + if (item.entity_type === 'park' && item.entity_slug) { + return `/parks/${item.entity_slug}`; + } + if (item.entity_type === 'ride' && item.park_slug && item.entity_slug) { + return `/parks/${item.park_slug}/rides/${item.entity_slug}`; + } + if (item.entity_type === 'company' && item.entity_slug) { + return `/manufacturers/${item.entity_slug}`; + } + if (item.entity_type === 'ride_model' && item.entity_slug) { + return `/models/${item.entity_slug}`; + } + return null; + }; + + if (isLoading) { + return ( + + + Latest Additions + + +
+
+
+
+
+ ); + } + + return ( + + +
+ Latest Additions (Newest First) + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ {filteredAdditions.length === 0 ? ( +
+ No additions found matching your filters. +
+ ) : ( + filteredAdditions.map((item) => { + const config = entityTypeConfig[item.entity_type]; + const Icon = config.icon; + const link = getEntityLink(item); + + return ( +
+
+ +
+ + {item.image_url && ( + {item.entity_name} + )} + +
+
+ + {config.label} + + {link ? ( + + {item.entity_name} + + ) : ( + + {item.entity_name} + + )} +
+
+ {item.created_by_username ? ( + <> + + + + {item.created_by_username[0].toUpperCase()} + + + @{item.created_by_username} + + ) : ( + System + )} + + {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} +
+
+
+ ); + }) + )} +
+
+
+ ); +} diff --git a/src/components/layout/AdminSidebar.tsx b/src/components/layout/AdminSidebar.tsx index dd8d544e..628f28db 100644 --- a/src/components/layout/AdminSidebar.tsx +++ b/src/components/layout/AdminSidebar.tsx @@ -1,4 +1,4 @@ -import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity } from 'lucide-react'; +import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart } from 'lucide-react'; import { NavLink } from 'react-router-dom'; import { useUserRole } from '@/hooks/useUserRole'; import { useSidebar } from '@/hooks/useSidebar'; @@ -68,6 +68,11 @@ export function AdminSidebar() { url: '/admin/rate-limit-metrics', icon: Shield, }, + { + title: 'Database Stats', + url: '/admin/database-stats', + icon: BarChart, + }, { title: 'Users', url: '/admin/users', diff --git a/src/hooks/useAdminDatabaseStats.ts b/src/hooks/useAdminDatabaseStats.ts new file mode 100644 index 00000000..5b3fb83b --- /dev/null +++ b/src/hooks/useAdminDatabaseStats.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; +import type { DatabaseStatistics } from '@/types/database-stats'; + +export function useAdminDatabaseStats() { + return useQuery({ + queryKey: queryKeys.admin.databaseStats(), + queryFn: async () => { + const { data, error } = await supabase.rpc('get_database_statistics'); + + if (error) { + throw error; + } + + return data as unknown as DatabaseStatistics; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchInterval: 60 * 1000, // Auto-refetch every 60 seconds + }); +} diff --git a/src/hooks/useRecentAdditions.ts b/src/hooks/useRecentAdditions.ts new file mode 100644 index 00000000..6f0d2a2a --- /dev/null +++ b/src/hooks/useRecentAdditions.ts @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { queryKeys } from '@/lib/queryKeys'; +import type { RecentAddition } from '@/types/database-stats'; +import { useEffect } from 'react'; + +export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) { + const query = useQuery({ + queryKey: queryKeys.admin.recentAdditions(limit), + queryFn: async () => { + const { data, error } = await supabase.rpc('get_recent_additions', { + limit_count: limit + }); + + if (error) { + throw error; + } + + return data as unknown as RecentAddition[]; + }, + staleTime: 2 * 60 * 1000, // 2 minutes + refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds + }); + + // Set up real-time subscriptions + useEffect(() => { + const channels = [ + supabase + .channel('recent_additions_parks') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'parks' }, () => { + query.refetch(); + }) + .subscribe(), + supabase + .channel('recent_additions_rides') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'rides' }, () => { + query.refetch(); + }) + .subscribe(), + supabase + .channel('recent_additions_companies') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'companies' }, () => { + query.refetch(); + }) + .subscribe(), + supabase + .channel('recent_additions_ride_models') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'ride_models' }, () => { + query.refetch(); + }) + .subscribe(), + supabase + .channel('recent_additions_photos') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'entity_photos' }, () => { + query.refetch(); + }) + .subscribe(), + ]; + + return () => { + channels.forEach(channel => channel.unsubscribe()); + }; + }, [query]); + + // Filter by entity type on client side + const filteredData = entityTypeFilter && query.data + ? query.data.filter(item => item.entity_type === entityTypeFilter) + : query.data; + + return { + ...query, + data: filteredData, + }; +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 37ba8eaf..9a68e0cb 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -6829,6 +6829,7 @@ export type Database = { generate_ticket_number: { Args: never; Returns: string } get_auth0_sub_from_jwt: { Args: never; Returns: string } get_current_user_id: { Args: never; Returns: string } + get_database_statistics: { Args: never; Returns: Json } get_email_change_status: { Args: never; Returns: Json } get_filtered_profile: { Args: { _profile_user_id: string; _viewer_id?: string } @@ -6853,6 +6854,21 @@ export type Database = { id: string }[] } + get_recent_additions: { + Args: { limit_count?: number } + Returns: { + created_at: string + created_by_avatar: string + created_by_id: string + created_by_username: string + entity_id: string + entity_name: string + entity_slug: string + entity_type: string + image_url: string + park_slug: string + }[] + } get_recent_changes: { Args: { limit_count?: number } Returns: { diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index ee4030a2..cc040640 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -98,4 +98,10 @@ export const queryKeys = { anomalyDetections: () => ['monitoring', 'anomaly-detections'] as const, dataRetentionStats: () => ['monitoring', 'data-retention-stats'] as const, }, + + // Admin queries + admin: { + databaseStats: () => ['admin', 'database-stats'] as const, + recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const, + }, } as const; diff --git a/src/pages/AdminDatabaseStats.tsx b/src/pages/AdminDatabaseStats.tsx new file mode 100644 index 00000000..765efe27 --- /dev/null +++ b/src/pages/AdminDatabaseStats.tsx @@ -0,0 +1,161 @@ +import { Building2, Bike, Factory, Users, FileText, TrendingUp, Box, MapPin, Calendar, Image as ImageIcon } from 'lucide-react'; +import { AdminLayout } from '@/components/layout/AdminLayout'; +import { useAdminGuard } from '@/hooks/useAdminGuard'; +import { DatabaseStatsCard } from '@/components/admin/database-stats/DatabaseStatsCard'; +import { RecentAdditionsTable } from '@/components/admin/database-stats/RecentAdditionsTable'; +import { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats'; +import { useRecentAdditions } from '@/hooks/useRecentAdditions'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle } from 'lucide-react'; + +export default function AdminDatabaseStats() { + const { isLoading, isAuthorized, needsMFA } = useAdminGuard(); + const { data: stats, isLoading: statsLoading, error: statsError } = useAdminDatabaseStats(); + const { data: recentAdditions, isLoading: additionsLoading } = useRecentAdditions(50); + + if (isLoading || statsLoading) { + return ( + +
+
+
+
+ ); + } + + if (!isAuthorized || needsMFA) { + return null; + } + + if (statsError) { + return ( + + + + + Failed to load database statistics. Please try again later. + + + + ); + } + + const totalEntities = stats + ? stats.parks.total + stats.rides.total + stats.companies.total + stats.ride_models.total + stats.locations.total + : 0; + + const recentAdditions7d = stats + ? stats.parks.added_7d + stats.rides.added_7d + stats.companies.added_7d + stats.ride_models.added_7d + stats.photos.added_7d + : 0; + + const recentAdditions30d = stats + ? stats.parks.added_30d + stats.rides.added_30d + stats.companies.added_30d + stats.ride_models.added_30d + stats.photos.added_30d + : 0; + + return ( + +
+
+

Database Statistics

+

+ Complete overview of database content and activity +

+
+ + {/* Stats Grid */} +
+ + + + + + + + + + + + + +
+ + {/* Recent Additions Table */} + +
+
+ ); +} diff --git a/src/types/database-stats.ts b/src/types/database-stats.ts new file mode 100644 index 00000000..71f29215 --- /dev/null +++ b/src/types/database-stats.ts @@ -0,0 +1,62 @@ +export interface DatabaseStatistics { + parks: { + total: number; + active: number; + historical: number; + added_7d: number; + added_30d: number; + }; + rides: { + total: number; + active: number; + historical: number; + added_7d: number; + added_30d: number; + }; + companies: { + total: number; + manufacturers: number; + operators: number; + designers: number; + added_7d: number; + added_30d: number; + }; + ride_models: { + total: number; + added_7d: number; + added_30d: number; + }; + locations: { + total: number; + }; + timeline_events: { + total: number; + }; + photos: { + total: number; + added_7d: number; + added_30d: number; + }; + users: { + total: number; + active_30d: number; + }; + submissions: { + pending: number; + approved: number; + rejected: number; + }; +} + +export interface RecentAddition { + entity_id: string; + entity_type: 'park' | 'ride' | 'company' | 'ride_model' | 'location' | 'timeline_event' | 'photo'; + entity_name: string; + entity_slug: string | null; + park_slug: string | null; + image_url: string | null; + created_at: string; + created_by_id: string | null; + created_by_username: string | null; + created_by_avatar: string | null; +} diff --git a/supabase/migrations/20251111164931_1e6bd08b-f386-42ca-afe5-ad7c2356b0e0.sql b/supabase/migrations/20251111164931_1e6bd08b-f386-42ca-afe5-ad7c2356b0e0.sql new file mode 100644 index 00000000..486591da --- /dev/null +++ b/supabase/migrations/20251111164931_1e6bd08b-f386-42ca-afe5-ad7c2356b0e0.sql @@ -0,0 +1,285 @@ +-- Function: Get comprehensive database statistics +CREATE OR REPLACE FUNCTION public.get_database_statistics() +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_stats jsonb; + v_parks_total integer; + v_parks_active integer; + v_parks_historical integer; + v_parks_7d integer; + v_parks_30d integer; + v_rides_total integer; + v_rides_active integer; + v_rides_historical integer; + v_rides_7d integer; + v_rides_30d integer; + v_companies_total integer; + v_manufacturers integer; + v_operators integer; + v_designers integer; + v_companies_7d integer; + v_companies_30d integer; + v_ride_models_total integer; + v_ride_models_7d integer; + v_ride_models_30d integer; + v_locations_total integer; + v_timeline_events_total integer; + v_photos_total integer; + v_photos_7d integer; + v_photos_30d integer; + v_users_total integer; + v_users_active_30d integer; + v_submissions_pending integer; + v_submissions_approved integer; + v_submissions_rejected integer; +BEGIN + -- Parks statistics + SELECT COUNT(*) INTO v_parks_total FROM parks; + SELECT COUNT(*) INTO v_parks_active FROM parks WHERE status = 'operating'; + SELECT COUNT(*) INTO v_parks_historical FROM parks WHERE status IN ('closed', 'historical'); + SELECT COUNT(*) INTO v_parks_7d FROM parks WHERE created_at > NOW() - INTERVAL '7 days'; + SELECT COUNT(*) INTO v_parks_30d FROM parks WHERE created_at > NOW() - INTERVAL '30 days'; + + -- Rides statistics + SELECT COUNT(*) INTO v_rides_total FROM rides; + SELECT COUNT(*) INTO v_rides_active FROM rides WHERE status = 'operating'; + SELECT COUNT(*) INTO v_rides_historical FROM rides WHERE status IN ('closed', 'removed', 'relocated'); + SELECT COUNT(*) INTO v_rides_7d FROM rides WHERE created_at > NOW() - INTERVAL '7 days'; + SELECT COUNT(*) INTO v_rides_30d FROM rides WHERE created_at > NOW() - INTERVAL '30 days'; + + -- Companies statistics + SELECT COUNT(*) INTO v_companies_total FROM companies; + SELECT COUNT(*) INTO v_manufacturers FROM companies WHERE company_type = 'manufacturer'; + SELECT COUNT(*) INTO v_operators FROM companies WHERE company_type = 'operator'; + SELECT COUNT(*) INTO v_designers FROM companies WHERE company_type = 'designer'; + SELECT COUNT(*) INTO v_companies_7d FROM companies WHERE created_at > NOW() - INTERVAL '7 days'; + SELECT COUNT(*) INTO v_companies_30d FROM companies WHERE created_at > NOW() - INTERVAL '30 days'; + + -- Ride models statistics + SELECT COUNT(*) INTO v_ride_models_total FROM ride_models; + SELECT COUNT(*) INTO v_ride_models_7d FROM ride_models WHERE created_at > NOW() - INTERVAL '7 days'; + SELECT COUNT(*) INTO v_ride_models_30d FROM ride_models WHERE created_at > NOW() - INTERVAL '30 days'; + + -- Locations statistics + SELECT COUNT(*) INTO v_locations_total FROM locations; + + -- Timeline events statistics + SELECT COUNT(*) INTO v_timeline_events_total FROM entity_timeline_events; + + -- Photos statistics + SELECT COUNT(*) INTO v_photos_total FROM entity_photos; + SELECT COUNT(*) INTO v_photos_7d FROM entity_photos WHERE uploaded_at > NOW() - INTERVAL '7 days'; + SELECT COUNT(*) INTO v_photos_30d FROM entity_photos WHERE uploaded_at > NOW() - INTERVAL '30 days'; + + -- Users statistics + SELECT COUNT(*) INTO v_users_total FROM profiles; + SELECT COUNT(*) INTO v_users_active_30d FROM profiles WHERE updated_at > NOW() - INTERVAL '30 days'; + + -- Submissions statistics + SELECT COUNT(*) INTO v_submissions_pending FROM content_submissions WHERE status = 'pending'; + SELECT COUNT(*) INTO v_submissions_approved FROM content_submissions WHERE status = 'approved'; + SELECT COUNT(*) INTO v_submissions_rejected FROM content_submissions WHERE status = 'rejected'; + + -- Build result JSON + v_stats := jsonb_build_object( + 'parks', jsonb_build_object( + 'total', v_parks_total, + 'active', v_parks_active, + 'historical', v_parks_historical, + 'added_7d', v_parks_7d, + 'added_30d', v_parks_30d + ), + 'rides', jsonb_build_object( + 'total', v_rides_total, + 'active', v_rides_active, + 'historical', v_rides_historical, + 'added_7d', v_rides_7d, + 'added_30d', v_rides_30d + ), + 'companies', jsonb_build_object( + 'total', v_companies_total, + 'manufacturers', v_manufacturers, + 'operators', v_operators, + 'designers', v_designers, + 'added_7d', v_companies_7d, + 'added_30d', v_companies_30d + ), + 'ride_models', jsonb_build_object( + 'total', v_ride_models_total, + 'added_7d', v_ride_models_7d, + 'added_30d', v_ride_models_30d + ), + 'locations', jsonb_build_object( + 'total', v_locations_total + ), + 'timeline_events', jsonb_build_object( + 'total', v_timeline_events_total + ), + 'photos', jsonb_build_object( + 'total', v_photos_total, + 'added_7d', v_photos_7d, + 'added_30d', v_photos_30d + ), + 'users', jsonb_build_object( + 'total', v_users_total, + 'active_30d', v_users_active_30d + ), + 'submissions', jsonb_build_object( + 'pending', v_submissions_pending, + 'approved', v_submissions_approved, + 'rejected', v_submissions_rejected + ) + ); + + RETURN v_stats; +END; +$$; + +-- Function: Get recent additions across all entity types +CREATE OR REPLACE FUNCTION public.get_recent_additions(limit_count integer DEFAULT 50) +RETURNS TABLE( + entity_id uuid, + entity_type text, + entity_name text, + entity_slug text, + park_slug text, + image_url text, + created_at timestamptz, + created_by_id uuid, + created_by_username text, + created_by_avatar text +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + RETURN QUERY + SELECT * FROM ( + -- Parks + SELECT + p.id as entity_id, + 'park'::text as entity_type, + p.name as entity_name, + p.slug as entity_slug, + NULL::text as park_slug, + p.card_image_url as image_url, + p.created_at, + p.created_by as created_by_id, + prof.username as created_by_username, + prof.avatar_url as created_by_avatar + FROM parks p + LEFT JOIN profiles prof ON prof.user_id = p.created_by + + UNION ALL + + -- Rides + SELECT + r.id as entity_id, + 'ride'::text as entity_type, + r.name as entity_name, + r.slug as entity_slug, + pk.slug as park_slug, + r.card_image_url as image_url, + r.created_at, + r.created_by as created_by_id, + prof.username as created_by_username, + prof.avatar_url as created_by_avatar + FROM rides r + LEFT JOIN parks pk ON pk.id = r.park_id + LEFT JOIN profiles prof ON prof.user_id = r.created_by + + UNION ALL + + -- Companies + SELECT + c.id as entity_id, + 'company'::text as entity_type, + c.name as entity_name, + c.slug as entity_slug, + NULL::text as park_slug, + c.card_image_url as image_url, + c.created_at, + c.created_by as created_by_id, + prof.username as created_by_username, + prof.avatar_url as created_by_avatar + FROM companies c + LEFT JOIN profiles prof ON prof.user_id = c.created_by + + UNION ALL + + -- Ride Models + SELECT + rm.id as entity_id, + 'ride_model'::text as entity_type, + rm.name as entity_name, + rm.slug as entity_slug, + NULL::text as park_slug, + rm.card_image_url as image_url, + rm.created_at, + rm.created_by as created_by_id, + prof.username as created_by_username, + prof.avatar_url as created_by_avatar + FROM ride_models rm + LEFT JOIN profiles prof ON prof.user_id = rm.created_by + + UNION ALL + + -- Locations + SELECT + l.id as entity_id, + 'location'::text as entity_type, + COALESCE(l.city || ', ' || l.country, l.country, 'Location') as entity_name, + NULL::text as entity_slug, + NULL::text as park_slug, + NULL::text as image_url, + l.created_at, + NULL::uuid as created_by_id, + NULL::text as created_by_username, + NULL::text as created_by_avatar + FROM locations l + + UNION ALL + + -- Timeline Events + SELECT + te.id as entity_id, + 'timeline_event'::text as entity_type, + te.event_title as entity_name, + NULL::text as entity_slug, + NULL::text as park_slug, + NULL::text as image_url, + te.created_at, + te.created_by as created_by_id, + prof.username as created_by_username, + prof.avatar_url as created_by_avatar + FROM entity_timeline_events te + LEFT JOIN profiles prof ON prof.user_id = te.created_by + + UNION ALL + + -- Photos + SELECT + ep.id as entity_id, + 'photo'::text as entity_type, + COALESCE(ep.title, 'Photo') as entity_name, + NULL::text as entity_slug, + NULL::text as park_slug, + ep.cloudflare_url as image_url, + ep.uploaded_at as created_at, + ep.uploaded_by as created_by_id, + prof.username as created_by_username, + prof.avatar_url as created_by_avatar + FROM entity_photos ep + LEFT JOIN profiles prof ON prof.user_id = ep.uploaded_by + ) combined + ORDER BY created_at DESC + LIMIT limit_count; +END; +$$; \ No newline at end of file