Implement admin database stats dashboard

Add admin-only database statistics dashboard
- Introduces types for database statistics and recent additions
- Implements hooks to fetch statistics and recent additions via RPCs
- Adds UI components for stats cards and a recent additions table
- Integrates new AdminDatabaseStats page and routing under /admin/database-stats
- Updates admin sidebar and app routes to expose the new dashboard
- Enables real-time updates and export capabilities for recent additions
This commit is contained in:
gpt-engineer-app[bot]
2025-11-11 16:54:02 +00:00
parent 69db3c7743
commit f036776dce
11 changed files with 906 additions and 1 deletions

View File

@@ -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 {
</AdminErrorBoundary>
}
/>
<Route
path="/admin/database-stats"
element={
<AdminErrorBoundary section="Database Statistics">
<AdminDatabaseStats />
</AdminErrorBoundary>
}
/>
{/* Utility routes - lazy loaded */}
<Route path="/force-logout" element={<ForceLogout />} />

View File

@@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className={cn("h-4 w-4 text-muted-foreground", iconClassName)} />
</CardHeader>
<CardContent>
<div className="space-y-2">
{stats.map((stat, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{stat.label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{stat.value.toLocaleString()}</span>
{stat.trend && (
<span className="text-xs text-muted-foreground">
+{stat.trend.value} ({stat.trend.period})
</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<string>('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 (
<Card>
<CardHeader>
<CardTitle>Latest Additions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Latest Additions (Newest First)</CardTitle>
<Button onClick={exportToCSV} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or creator..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={entityTypeFilter} onValueChange={setEntityTypeFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="park">Parks</SelectItem>
<SelectItem value="ride">Rides</SelectItem>
<SelectItem value="company">Companies</SelectItem>
<SelectItem value="ride_model">Ride Models</SelectItem>
<SelectItem value="location">Locations</SelectItem>
<SelectItem value="timeline_event">Timeline Events</SelectItem>
<SelectItem value="photo">Photos</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
{filteredAdditions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No additions found matching your filters.
</div>
) : (
filteredAdditions.map((item) => {
const config = entityTypeConfig[item.entity_type];
const Icon = config.icon;
const link = getEntityLink(item);
return (
<div
key={`${item.entity_type}-${item.entity_id}`}
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className={`p-2 rounded-lg ${config.color} bg-opacity-10`}>
<Icon className="h-5 w-5" />
</div>
{item.image_url && (
<img
src={item.image_url}
alt={item.entity_name}
className="h-12 w-12 rounded object-cover"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
{link ? (
<Link
to={link}
className="font-medium text-sm hover:underline truncate"
>
{item.entity_name}
</Link>
) : (
<span className="font-medium text-sm truncate">
{item.entity_name}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.created_by_username ? (
<>
<Avatar className="h-4 w-4">
<AvatarImage src={item.created_by_avatar || undefined} />
<AvatarFallback className="text-[8px]">
{item.created_by_username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<span>@{item.created_by_username}</span>
</>
) : (
<span>System</span>
)}
<span></span>
<span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span>
</div>
</div>
</div>
);
})
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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',

View File

@@ -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
});
}

View File

@@ -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,
};
}

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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 (
<AdminLayout>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
</AdminLayout>
);
}
if (!isAuthorized || needsMFA) {
return null;
}
if (statsError) {
return (
<AdminLayout>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load database statistics. Please try again later.
</AlertDescription>
</Alert>
</AdminLayout>
);
}
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 (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Database Statistics</h1>
<p className="text-muted-foreground mt-2">
Complete overview of database content and activity
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<DatabaseStatsCard
title="Total Entities"
icon={Box}
iconClassName="text-blue-500"
stats={[
{ label: 'All Entities', value: totalEntities },
{ label: 'Parks', value: stats?.parks.total || 0 },
{ label: 'Rides', value: stats?.rides.total || 0 },
{ label: 'Companies', value: stats?.companies.total || 0 },
{ label: 'Ride Models', value: stats?.ride_models.total || 0 },
]}
/>
<DatabaseStatsCard
title="Recent Activity"
icon={TrendingUp}
iconClassName="text-green-500"
stats={[
{
label: 'Added (7 days)',
value: recentAdditions7d,
},
{
label: 'Added (30 days)',
value: recentAdditions30d,
},
]}
/>
<DatabaseStatsCard
title="Parks & Rides"
icon={Building2}
iconClassName="text-purple-500"
stats={[
{ label: 'Active Parks', value: stats?.parks.active || 0 },
{ label: 'Historical Parks', value: stats?.parks.historical || 0 },
{ label: 'Active Rides', value: stats?.rides.active || 0 },
{ label: 'Historical Rides', value: stats?.rides.historical || 0 },
]}
/>
<DatabaseStatsCard
title="Content"
icon={ImageIcon}
iconClassName="text-orange-500"
stats={[
{ label: 'Photos', value: stats?.photos.total || 0 },
{ label: 'Locations', value: stats?.locations.total || 0 },
{ label: 'Timeline Events', value: stats?.timeline_events.total || 0 },
]}
/>
<DatabaseStatsCard
title="Companies"
icon={Factory}
iconClassName="text-amber-500"
stats={[
{ label: 'Total', value: stats?.companies.total || 0 },
{ label: 'Manufacturers', value: stats?.companies.manufacturers || 0 },
{ label: 'Operators', value: stats?.companies.operators || 0 },
{ label: 'Designers', value: stats?.companies.designers || 0 },
]}
/>
<DatabaseStatsCard
title="User Activity"
icon={Users}
iconClassName="text-teal-500"
stats={[
{ label: 'Total Users', value: stats?.users.total || 0 },
{ label: 'Active (30 days)', value: stats?.users.active_30d || 0 },
]}
/>
<DatabaseStatsCard
title="Submissions"
icon={FileText}
iconClassName="text-pink-500"
stats={[
{ label: 'Pending', value: stats?.submissions.pending || 0 },
{ label: 'Approved', value: stats?.submissions.approved || 0 },
{ label: 'Rejected', value: stats?.submissions.rejected || 0 },
]}
/>
</div>
{/* Recent Additions Table */}
<RecentAdditionsTable
additions={recentAdditions || []}
isLoading={additionsLoading}
/>
</div>
</AdminLayout>
);
}

View File

@@ -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;
}

View File

@@ -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;
$$;