Compare commits

..

47 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
68384156ab Fix selective-approval RPC params
Update edge function to call process_approval_transaction with correct parameters:
- remove p_trace_id and p_parent_span_id
- add p_approval_mode: 'selective' and p_idempotency_key: idempotencyKey
This aligns with database function signature and resolves 500 error.

X-Lovable-Edit-ID: edt-6e45b77e-1d54-4173-af1a-dcbcd886645d
2025-11-12 15:12:31 +00:00
gpt-engineer-app[bot]
5cc5d3eab6 testing changes with virtual file cleanup 2025-11-12 15:12:30 +00:00
gpt-engineer-app[bot]
706e36c847 Add staggered expand animation
Implement sequential delays for detailed view expansions:
- Add staggerIndex prop support to DetailedViewCollapsible and apply per-item animation delays.
- Pass item index in SubmissionItemsList when rendering detailed sections.
- Ensure each detailed view expands with a 50ms incremental delay (up to a max) for a staggered effect.

X-Lovable-Edit-ID: edt-6eb47d5c-853d-43ab-96a7-16a5cc006c30
2025-11-12 14:56:11 +00:00
gpt-engineer-app[bot]
a1beba6996 testing changes with virtual file cleanup 2025-11-12 14:56:11 +00:00
gpt-engineer-app[bot]
d7158756ef Animate detailed view transitions
Improve user experience by adding smooth animation transitions to expand/collapse of All Fields (Detailed View) sections, enhance collapsible base to support animation, and apply transitions to detailed view wrapper and chevron indicators.

X-Lovable-Edit-ID: edt-9a567ba5-b52f-46b3-bdef-b847b9ba7963
2025-11-12 14:53:19 +00:00
gpt-engineer-app[bot]
3330a8fac9 testing changes with virtual file cleanup 2025-11-12 14:53:18 +00:00
gpt-engineer-app[bot]
c09a343d08 Add moderation_preferences column
Adds a JSONB moderation_preferences column to user_preferences (with default '{}'), plus comment and GIN index, enabling per-user persistence of detailed view state and resolving TS errors.

X-Lovable-Edit-ID: edt-b953d926-c053-45f2-b434-2b776f3d9569
2025-11-12 14:50:57 +00:00
gpt-engineer-app[bot]
9893567a30 testing changes with virtual file cleanup 2025-11-12 14:50:56 +00:00
gpt-engineer-app[bot]
771405961f Add tooltip for expanded count
Enhance persistence for moderator preferences

- Add tooltip to moderation queue toggle showing number of items with detailed views expanded (based on global state, tooltip adapts to expanded/collapsed).
- Persist expanded/collapsed state per moderator in the database instead of localStorage, integrating with user preferences and Supabase backend.

X-Lovable-Edit-ID: edt-61e75a20-f83d-40b2-8bc4-b6ff40b23450
2025-11-12 14:45:07 +00:00
gpt-engineer-app[bot]
437e2b353c testing changes with virtual file cleanup 2025-11-12 14:45:06 +00:00
gpt-engineer-app[bot]
44a713af62 Add global toggle for detailed views
Implement a new global control in the moderation queue header to expand/collapse all "All Fields (Detailed View)" sections at once. This includes:
- Integrating useDetailedViewState with a new header-level button in QueueFilters
- Adding a button that toggles all detailed views and shows Expand/Collapse state
- Ensuring the toggle updates all DetailedViewCollapsible instances via shared state
- Keeping UI consistent with existing icons and styling

X-Lovable-Edit-ID: edt-22d9eca7-0c70-44d8-865d-791ef884dfbd
2025-11-12 14:42:34 +00:00
gpt-engineer-app[bot]
46275e0f1e testing changes with virtual file cleanup 2025-11-12 14:42:33 +00:00
gpt-engineer-app[bot]
6bd7d24a1b Add item-level history badge and animations
- Show a dynamic field-count badge next to All Fields (Detailed View) in the moderation queue
- Animate collapsible sections with smooth transitions for expand/collapse
- Pass fieldCount to DetailedViewCollapsible and render count alongside header; add animation utility in DetailedViewCollapsible.tsx
- Ensure SubmissionItemsList passes item data to calculate field counts and display badges accordingly

X-Lovable-Edit-ID: edt-ffd226b0-af99-491b-b6b8-3fe0063e0082
2025-11-12 14:40:52 +00:00
gpt-engineer-app[bot]
72e76e86af testing changes with virtual file cleanup 2025-11-12 14:40:51 +00:00
gpt-engineer-app[bot]
a35486fb11 Add collapsible detailed view
Implements expand/collapse for the All Fields (Detailed View) sections in the moderation queue:
- Adds useDetailedViewState hook to persist collapse state in localStorage (default collapsed)
- Adds DetailedViewCollapsible wrapper component using Radix Collapsible
- Updates SubmissionItemsList to wrap each detailed view block with the new collapsible, and imports the new hook and component

X-Lovable-Edit-ID: edt-a95a840d-e7e7-4f9e-aa25-03bb68194aee
2025-11-12 14:36:50 +00:00
gpt-engineer-app[bot]
3d3ae57ee3 testing changes with virtual file cleanup 2025-11-12 14:36:49 +00:00
gpt-engineer-app[bot]
46c08e10e8 Add item-level approval history
Introduce ItemLevelApprovalHistory component to display which specific submission items were approved, when, and by whom, and integrate it into QueueItem between metadata and audit trail. The component shows item names, approval timestamps, action types, and reviewer info.
2025-11-12 14:28:50 +00:00
gpt-engineer-app[bot]
b22546e7f2 Add audit trail and filters
Implements audit trail view for item approvals, adds approval date range filtering to moderation queue, and wires up UI and backend components (Approval History page, ItemApprovalHistory component, materialized view-based history, and query/filters integration) to support compliant reporting and time-based moderation filtering.
2025-11-12 14:06:34 +00:00
gpt-engineer-app[bot]
7b0825e772 Add approved_at support
Added approved_at column to submission_items, updated process_approval_transaction to set approved_at on approvals, and updated TypeScript types to include approved_at. migrations and generated types updated accordingly.
2025-11-12 13:45:30 +00:00
gpt-engineer-app[bot]
1a57b4f33f Add approved_at support
- Add approved_at column to submission_items and index
- Update process_approval_transaction to set approved_at on approval
- Extend TypeScript types to include approved_at for submission items
2025-11-12 13:40:07 +00:00
gpt-engineer-app[bot]
4c7731410f Add approved_at column and update flow
Implements migration to add approved_at to submission_items, creates index, and updates process_approval_transaction to set approved_at on approvals. Also updates TypeScript types to include approved_at and aligns edge function behavior accordingly.
2025-11-12 13:36:22 +00:00
gpt-engineer-app[bot]
beacf481d8 Fix approved_at reference
Recreated process_approval_transaction RPC without updating approved_at
drops erroneous column usage and updates updated_at explicitly to mark approvals.
2025-11-12 13:31:05 +00:00
gpt-engineer-app[bot]
00054f817d Fix edge function serve wrap
Add missing serve() wrapper and correct CORS configuration in supabase/functions/process-selective-approval/index.ts, and import necessary modules. This ensures the edge function handles requests and applies proper CORS headers.
2025-11-12 13:21:16 +00:00
gpt-engineer-app[bot]
d18632c2b2 Improve moderation edge flow and timeout handling
- Add early logging and health check to process-selective-approval edge function
- Implement idempotency check with timeout to avoid edge timeouts
- Expose health endpoint for connectivity diagnostics
- Increase client moderation action timeout from 30s to 60s
- Update moderation actions hook to accommodate longer timeouts
2025-11-12 13:15:54 +00:00
gpt-engineer-app[bot]
09c320f508 Remove version number badge from VersionIndicator
Replace the badge with unchanged Last edited and View History UI, keeping dialog and history functionality intact. Update RollbackDialog to support preview of changes and wider dialog, and integrate existing diff logic to preview before rollback. Adjust VersionIndicator to stop displaying version number while preserving UI elements.
2025-11-12 05:06:45 +00:00
gpt-engineer-app[bot]
8422bc378f Remove version number badge from Indicator
Remove the version number badge display in VersionIndicator.tsx:
- drop Badge import and Clock icon import
- replace compact mode text with History
- remove version number badge in non-compact rendering
 Keeps last-edited time and history functionality intact.
2025-11-12 05:03:25 +00:00
gpt-engineer-app[bot]
5531376edf Fix span duplicates and metrics
Implements complete plan to resolve duplicate span_id issues and metric collection errors:
- Ensure edge handlers return proper Response objects to prevent double logging
- Update collect-metrics to use valid metric categories, fix system_alerts query, and adjust returns
- Apply detect-anomalies adjustments if needed and add defensive handling in wrapper
- Prepare ground for end-to-end verification of location-related fixes
2025-11-12 04:57:54 +00:00
gpt-engineer-app[bot]
b6d1b99f2b Update process_approval_transaction for locations
Applies the fix to properly handle and insert park location names during approval, ensuring location data is created/updated with correct name fields for Lagoon and future submissions.
2025-11-12 04:53:48 +00:00
gpt-engineer-app[bot]
d24de6a9e6 Fix location name handling in approval
Implement comprehensive update to process_approval_transaction to ensure location records are created with proper name fields during park creation and updates, and prepare migration to apply fixes in multiple phases. Includes backfill improvements and targeted fixes for Lagoon.
2025-11-12 04:42:21 +00:00
gpt-engineer-app[bot]
c3cab84132 Fix Lagoon location backfill
Backfilled Lagoon park with a proper location by updating backfill_park_locations to include name/display data and linking to location_id. Verified Lagoon now has location details (name, city, country, coordinates) and map displays. Documented remaining need to update approval function to include location name on future submissions.
2025-11-12 04:34:31 +00:00
gpt-engineer-app[bot]
ab9d424240 Backfill Lagoon location
Investigate Lagoon park to ensure location_id is populated after the backfill. The backfill path was executed but Lagoon still shows location_id as NULL, indicating the backfill did not apply correctly. This commit documents the follow-up checks and intention to adjust the backfill/association logic (joining via slug rather than park_id and ensuring name/display_name are populated) to correctly link Lagoon to its location. No frontend changes.
2025-11-12 04:33:19 +00:00
gpt-engineer-app[bot]
617e079c5a Backfill park locations again
Run backfill to populate lagoon location and fix submission join
- Re-run and refine backfill to ensure Lagoon and similar parks receive proper location data by correcting join via slug and handling name/display_name in location inserts.
2025-11-12 04:32:39 +00:00
gpt-engineer-app[bot]
3cb2c39acf Backfill Lagoon Location
Implement one-time script to populate missing park locations ( Lagoon and others ) by creating a backfill that assigns proper location data and updates parks with new location_id after ensuring backfill function fixes.
2025-11-12 04:28:16 +00:00
gpt-engineer-app[bot]
3867d30aac Enhance loading skeletons and breadcrumbs
- Add content-m matching loading skeletons for ParkDetail, RideDetail, CompanyDetail, etc., replacing generic spinners to preserve layout during load
- Remove redundant Back to Parent Entity buttons in detail pages in favor of breadcrumb navigation
- Prepare groundwork for breadcrumbs across detail pages to improve cohesion and navigation
2025-11-12 03:51:15 +00:00
gpt-engineer-app[bot]
fdfa1739e5 Add breadcrumb and transitions
Introduce breadcrumb navigation component and integrate into detail pages with hover previews; add PageTransition to App for smooth navigations and loading animations.
2025-11-12 03:46:34 +00:00
gpt-engineer-app[bot]
361231bfac Add hover preview cards
Adds hover-preview UX by introducing preview cards for entities and wiring hoverable links:
- Implements CompanyPreviewCard and ParkPreviewCard components plus hooks to fetch preview data
- Adds HoverCard usage to ParkDetail and RideDetail for operator, manufacturer, and designer links
- Creates preview wrappers for manufacturer/designer/operator links and updates related pages to use hover previews
- Includes supporting updates to query keys and preview hooks to fetch minimal data for previews
2025-11-12 03:44:01 +00:00
gpt-engineer-app[bot]
2ccfe8c48a Make entity names clickable
Update various components to wrap display names (parks, rides, manufacturers, designers, operators, etc.) in Link elements so they navigate to detail pages, aligning with the ParkDetail change. This includesRideDetail, RideListView, RideCreditCard, ParkDetail, and related imports, to enhance cohesion, interactivity, and browseability across the app.
2025-11-12 03:40:29 +00:00
gpt-engineer-app[bot]
fd4e21734f Make operator name clickable
Update ParkDetail to wrap park.operator.name in a Link to the operator page, and import Link from react-router-dom. This changes the display from plain text to a navigable link and fixes missing import.
2025-11-12 03:36:15 +00:00
gpt-engineer-app[bot]
9bab4358e3 Fix analyze_data_completeness fields
Update parameterized analyze_data_completeness to check parks, rides, companies, and ride_models using appropriate fields instead of manufacturer_id for parks. Also replace get_recent_additions te.event_title with te.title.
2025-11-12 03:30:43 +00:00
gpt-engineer-app[bot]
5b5bd4d62e Fix analyze_data_completeness search_path
Update the analyze_data_completeness function to set search_path = public to address security linter warning and ensure consistent function execution without elevated schema access. No behavioral changes beyond security context.
2025-11-12 02:04:59 +00:00
gpt-engineer-app[bot]
d435bda06a Fix search_path in function
- Aligns database migration by adding a follow-up migration to set the search_path for the affected function to address a security linter warning and ensure proper execution scope.
2025-11-12 02:03:32 +00:00
gpt-engineer-app[bot]
888ef0224a Update submission_metadata queries
Enhance frontend to correctly fetch entity names by joining with submission_metadata and filtering metadata_key = 'name'; replace incorrect submission_metadata(name) usage in systemActivityService.ts and Profile.tsx with proper inner join and metadata_value extraction.
2025-11-12 01:53:51 +00:00
gpt-engineer-app[bot]
78e29f9e49 Remove created_by joins and fix metadata queries
Fix get_recent_additions by removing created_by joins for tables without such column, leaving creator data only for entity_timeline_events. Add migration to implement corrected logic. Update frontend queries handling content_submissions metadata to correctly extract entity names from submission_metadata. Also apply guarded admin-only execution for data completeness hook to prevent non-admin queries.
2025-11-12 01:53:02 +00:00
gpt-engineer-app[bot]
842861af8c Fix JSONB filter and admin guard
- Implement helper filter_jsonb_array_nulls and apply it in analyze_data_completeness to replace jsonb - 'null' usage
- Add search_path public in helper function for security
- Ensure useDataCompleteness runs only on admin pages by enabling query conditionally
2025-11-12 01:45:56 +00:00
gpt-engineer-app[bot]
348ab23d26 Fix analyze_data_completeness jsonb
- Add safe JSONB array null filtering via helper and update analyze_data_completeness
- Ensure search_path set to public in helper function
- Add admin-page guard to useDataCompleteness to disable on non-admin routes
2025-11-12 01:45:22 +00:00
gpt-engineer-app[bot]
b58a0a7741 Fix analyze_data_completeness and admin guard
- Add migration to fix JSONB array filtering in analyze_data_completeness
- Update useDataCompleteness to run only on admin pages via isAdminPage check
- Prepare for replacing invalid JSONB subtraction with proper filtering in RPCs
2025-11-12 01:44:58 +00:00
gpt-engineer-app[bot]
e2ee11b9f5 Add database maintenance tooling
- Implement maintenance hooks (useMaintenanceTables, useVacuumTable, useAnalyzeTable, useReindexTable)
- Add DatabaseMaintenance page and UI for vacuum/analyze/reindex
- Wire new route / admin/database-maintenance and sidebar entry
- Remove DatabaseMaintenance icon usage on page and align with AdminLayout props
2025-11-12 01:39:05 +00:00
70 changed files with 6990 additions and 235 deletions

View File

@@ -0,0 +1,183 @@
# Location Handling Fix - Complete Summary
## Problem Identified
Parks were being created without location data due to a critical bug in the approval pipeline. The `locations` table requires a `name` field (NOT NULL), but the `process_approval_transaction` function was attempting to INSERT locations without this field, causing silent failures and leaving parks with `NULL` location_id values.
## Root Cause
The function was:
1. ✅ Correctly JOINing `park_submission_locations` table
2. ✅ Fetching location fields like `country`, `city`, `latitude`, etc.
3.**NOT** fetching the `name` or `display_name` fields
4.**NOT** including `name` field in the INSERT statement
This caused PostgreSQL to reject the INSERT (violating NOT NULL constraint), but since there was no explicit error handling for this specific failure, the park was still created with `location_id = NULL`.
## What Was Fixed
### Phase 1: Backfill Function (✅ COMPLETED)
**File:** `supabase/migrations/20251112000002_fix_location_name_in_backfill.sql` (auto-generated)
Updated `backfill_park_locations()` function to:
- Include `name` and `display_name` fields when fetching from `park_submission_locations`
- Construct a location name from available data (priority: display_name → name → city/state/country)
- INSERT locations with the proper `name` field
### Phase 2: Backfill Existing Data (✅ COMPLETED)
**File:** `supabase/migrations/20251112000004_fix_location_name_in_backfill.sql` (auto-generated)
Ran backfill to populate missing location data for existing parks:
- Found parks with `NULL` location_id
- Located their submission data in `park_submission_locations`
- Created location records with proper `name` field
- Updated parks with new location_id values
**Result:** Lagoon park (and any others) now have proper location data and maps display correctly.
### Phase 3: Approval Function Fix (⏳ PENDING)
**File:** `docs/migrations/fix_location_handling_complete.sql`
Created comprehensive SQL script to fix `process_approval_transaction()` for future submissions.
**Key Changes:**
1. Added to SELECT clause (line ~108):
```sql
psl.name as park_location_name,
psl.display_name as park_location_display_name,
```
2. Updated CREATE action location INSERT (line ~204):
```sql
v_location_name := COALESCE(
v_item.park_location_display_name,
v_item.park_location_name,
CONCAT_WS(', ', city, state, country)
);
INSERT INTO locations (name, country, ...)
VALUES (v_location_name, v_item.park_location_country, ...)
```
3. Updated UPDATE action location INSERT (line ~454):
```sql
-- Same logic as CREATE action
```
## How to Apply the Approval Function Fix
The complete SQL script is ready in `docs/migrations/fix_location_handling_complete.sql`.
### Option 1: Via Supabase SQL Editor (Recommended)
1. Go to [Supabase SQL Editor](https://supabase.com/dashboard/project/ydvtmnrszybqnbcqbdcy/sql/new)
2. Copy the contents of `docs/migrations/fix_location_handling_complete.sql`
3. Paste and execute the SQL
4. Verify success by checking the function exists
### Option 2: Via Migration Tool (Later)
The migration can be split into smaller chunks if needed, but the complete file is ready for manual application.
## Verification Steps
### 1. Verify Existing Parks Have Locations
```sql
SELECT p.name, p.slug, p.location_id, l.name as location_name
FROM parks p
LEFT JOIN locations l ON p.location_id = l.id
WHERE p.slug = 'lagoon';
```
**Expected Result:** Location data should be populated ✅
### 2. Test New Park Submission (After Applying Fix)
1. Create a new park submission with location data
2. Submit for moderation
3. Approve the submission
4. Verify the park has a non-NULL location_id
5. Check the locations table has the proper name field
6. Verify the map displays on the park detail page
### 3. Test Park Update with Location Change
1. Edit an existing park and change its location
2. Submit for moderation
3. Approve the update
4. Verify a new location record was created with proper name
5. Verify the park's location_id was updated
## Database Schema Context
### locations Table Structure
```sql
- id: uuid (PK)
- name: text (NOT NULL) ← This was the missing field
- country: text
- state_province: text
- city: text
- street_address: text
- postal_code: text
- latitude: numeric
- longitude: numeric
- timezone: text
- created_at: timestamp with time zone
```
### park_submission_locations Table Structure
```sql
- id: uuid (PK)
- park_submission_id: uuid (FK)
- name: text ← We weren't fetching this
- display_name: text ← We weren't fetching this
- country: text
- state_province: text
- city: text
- street_address: text
- postal_code: text
- latitude: numeric
- longitude: numeric
- timezone: text
- created_at: timestamp with time zone
```
## Impact Assessment
### Before Fix
- ❌ Parks created without location data (location_id = NULL)
- ❌ Maps not displaying on park detail pages
- ❌ Location-based features not working
- ❌ Silent failures in approval pipeline
### After Complete Fix
- ✅ All existing parks have location data (backfilled)
- ✅ Maps display correctly on park detail pages
- ✅ Future park submissions will have locations created properly
- ✅ Park updates with location changes work correctly
- ✅ No more silent failures in the pipeline
## Files Created
1. `docs/migrations/fix_location_handling_complete.sql` - Complete SQL script for approval function fix
2. `docs/LOCATION_FIX_SUMMARY.md` - This document
## Next Steps
1. **Immediate:** Apply the fix from `docs/migrations/fix_location_handling_complete.sql`
2. **Testing:** Run verification steps above
3. **Monitoring:** Watch for any location-related errors in production
4. **Documentation:** Update team on the fix and new behavior
## Related Issues
This fix ensures compliance with the "Sacred Pipeline" architecture documented in `docs/SUBMISSION_FLOW.md`. All location data flows through:
1. User form input
2. Submission to `park_submission_locations` table
3. Moderation queue review
4. Approval via `process_approval_transaction` function
5. Location creation in `locations` table
6. Park creation/update with proper location_id reference
## Additional Notes
- The `display_name` field in `park_submission_locations` is used for human-readable location labels (e.g., "375, Lagoon Drive, Farmington, Davis County, Utah, 84025, United States")
- The `name` field in `locations` must be populated for the INSERT to succeed
- If neither display_name nor name is provided, we construct it from city/state/country as a fallback
- This pattern should be applied to any other entities that use location data in the future

View File

@@ -0,0 +1,439 @@
-- ============================================================================
-- COMPLETE FIX: Location Name Handling in Approval Pipeline
-- ============================================================================
--
-- PURPOSE:
-- This migration fixes the process_approval_transaction function to properly
-- handle location names when creating parks. Without this fix, locations are
-- created without the 'name' field, causing silent failures and parks end up
-- with NULL location_id values.
--
-- WHAT THIS FIXES:
-- 1. Adds park_location_name and park_location_display_name to the SELECT
-- 2. Creates locations with proper name field during CREATE actions
-- 3. Creates locations with proper name field during UPDATE actions
-- 4. Falls back to constructing name from city/state/country if not provided
--
-- TESTING:
-- After applying, test by:
-- 1. Creating a new park submission with location data
-- 2. Approving the submission
-- 3. Verifying the park has a location_id set
-- 4. Checking the locations table has a record with proper name field
--
-- DEPLOYMENT:
-- This can be run manually via Supabase SQL Editor or applied as a migration
-- ============================================================================
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION process_approval_transaction(
p_submission_id UUID,
p_item_ids UUID[],
p_moderator_id UUID,
p_submitter_id UUID,
p_request_id TEXT DEFAULT NULL,
p_trace_id TEXT DEFAULT NULL,
p_parent_span_id TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_start_time TIMESTAMPTZ;
v_result JSONB;
v_item RECORD;
v_entity_id UUID;
v_approval_results JSONB[] := ARRAY[]::JSONB[];
v_final_status TEXT;
v_all_approved BOOLEAN := TRUE;
v_some_approved BOOLEAN := FALSE;
v_items_processed INTEGER := 0;
v_span_id TEXT;
v_resolved_park_id UUID;
v_resolved_manufacturer_id UUID;
v_resolved_ride_model_id UUID;
v_resolved_operator_id UUID;
v_resolved_property_owner_id UUID;
v_resolved_location_id UUID;
v_location_name TEXT;
BEGIN
v_start_time := clock_timestamp();
v_span_id := gen_random_uuid()::text;
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
v_span_id, p_trace_id, p_parent_span_id, EXTRACT(EPOCH FROM v_start_time) * 1000, p_submission_id, array_length(p_item_ids, 1);
END IF;
RAISE NOTICE '[%] Starting atomic approval transaction for submission %', COALESCE(p_request_id, 'NO_REQUEST_ID'), p_submission_id;
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
PERFORM set_config('app.submission_id', p_submission_id::text, true);
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
IF NOT EXISTS (
SELECT 1 FROM content_submissions
WHERE id = p_submission_id AND (assigned_to = p_moderator_id OR assigned_to IS NULL) AND status IN ('pending', 'partially_approved')
) THEN
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed' USING ERRCODE = '42501';
END IF;
-- ========================================================================
-- CRITICAL FIX: Added park_location_name and park_location_display_name
-- ========================================================================
FOR v_item IN
SELECT si.*,
ps.name as park_name, ps.slug as park_slug, ps.description as park_description, ps.park_type, ps.status as park_status,
ps.location_id, ps.operator_id, ps.property_owner_id, ps.opening_date as park_opening_date, ps.closing_date as park_closing_date,
ps.opening_date_precision as park_opening_date_precision, ps.closing_date_precision as park_closing_date_precision,
ps.website_url as park_website_url, ps.phone as park_phone, ps.email as park_email,
ps.banner_image_url as park_banner_image_url, ps.banner_image_id as park_banner_image_id,
ps.card_image_url as park_card_image_url, ps.card_image_id as park_card_image_id,
psl.name as park_location_name, psl.display_name as park_location_display_name,
psl.country as park_location_country, psl.state_province as park_location_state, psl.city as park_location_city,
psl.street_address as park_location_street, psl.postal_code as park_location_postal,
psl.latitude as park_location_lat, psl.longitude as park_location_lng, psl.timezone as park_location_timezone,
rs.name as ride_name, rs.slug as ride_slug, rs.park_id as ride_park_id, rs.category as ride_category, rs.status as ride_status,
rs.manufacturer_id, rs.ride_model_id, rs.opening_date as ride_opening_date, rs.closing_date as ride_closing_date,
rs.opening_date_precision as ride_opening_date_precision, rs.closing_date_precision as ride_closing_date_precision,
rs.description as ride_description, rs.banner_image_url as ride_banner_image_url, rs.banner_image_id as ride_banner_image_id,
rs.card_image_url as ride_card_image_url, rs.card_image_id as ride_card_image_id,
cs.name as company_name, cs.slug as company_slug, cs.description as company_description, cs.company_type,
cs.website_url as company_website_url, cs.founded_year, cs.founded_date, cs.founded_date_precision,
cs.headquarters_location, cs.logo_url, cs.person_type,
cs.banner_image_url as company_banner_image_url, cs.banner_image_id as company_banner_image_id,
cs.card_image_url as company_card_image_url, cs.card_image_id as company_card_image_id,
rms.name as ride_model_name, rms.slug as ride_model_slug, rms.manufacturer_id as ride_model_manufacturer_id,
rms.category as ride_model_category, rms.description as ride_model_description,
rms.banner_image_url as ride_model_banner_image_url, rms.banner_image_id as ride_model_banner_image_id,
rms.card_image_url as ride_model_card_image_url, rms.card_image_id as ride_model_card_image_id,
phs.entity_id as photo_entity_id, phs.entity_type as photo_entity_type, phs.title as photo_title
FROM submission_items si
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
LEFT JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
WHERE si.id = ANY(p_item_ids)
ORDER BY si.order_index, si.created_at
LOOP
BEGIN
v_items_processed := v_items_processed + 1;
v_entity_id := NULL;
v_resolved_park_id := NULL; v_resolved_manufacturer_id := NULL; v_resolved_ride_model_id := NULL;
v_resolved_operator_id := NULL; v_resolved_property_owner_id := NULL; v_resolved_location_id := NULL;
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
p_trace_id, v_span_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_item.id, v_item.item_type, v_item.action_type;
END IF;
IF v_item.action_type = 'create' THEN
IF v_item.item_type = 'park' THEN
-- ========================================================================
-- CRITICAL FIX: Create location with name field
-- ========================================================================
IF v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL THEN
-- Construct a name for the location, prioritizing display_name, then name, then city/state/country
v_location_name := COALESCE(
v_item.park_location_display_name,
v_item.park_location_name,
CONCAT_WS(', ',
NULLIF(v_item.park_location_city, ''),
NULLIF(v_item.park_location_state, ''),
NULLIF(v_item.park_location_country, '')
)
);
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (
v_location_name,
v_item.park_location_country,
v_item.park_location_state,
v_item.park_location_city,
v_item.park_location_street,
v_item.park_location_postal,
v_item.park_location_lat,
v_item.park_location_lng,
v_item.park_location_timezone
)
RETURNING id INTO v_resolved_location_id;
RAISE NOTICE '[%] Created location % (name: %) for park submission',
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
END IF;
-- Resolve temporary references
IF v_item.operator_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_operator_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('operator', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
IF v_item.property_owner_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_property_owner_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('property_owner', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
INSERT INTO parks (name, slug, description, park_type, status, location_id, operator_id, property_owner_id,
opening_date, closing_date, opening_date_precision, closing_date_precision, website_url, phone, email,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.park_name, v_item.park_slug, v_item.park_description, v_item.park_type, v_item.park_status,
COALESCE(v_resolved_location_id, v_item.location_id),
COALESCE(v_item.operator_id, v_resolved_operator_id),
COALESCE(v_item.property_owner_id, v_resolved_property_owner_id),
v_item.park_opening_date, v_item.park_closing_date,
v_item.park_opening_date_precision, v_item.park_closing_date_precision,
v_item.park_website_url, v_item.park_phone, v_item.park_email,
v_item.park_banner_image_url, v_item.park_banner_image_id,
v_item.park_card_image_url, v_item.park_card_image_id
)
RETURNING id INTO v_entity_id;
ELSIF v_item.item_type = 'ride' THEN
IF v_item.ride_park_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_park_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type = 'park' AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
IF v_item.manufacturer_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
IF v_item.ride_model_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_ride_model_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type = 'ride_model' AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
INSERT INTO rides (name, slug, park_id, category, status, manufacturer_id, ride_model_id,
opening_date, closing_date, opening_date_precision, closing_date_precision, description,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.ride_name, v_item.ride_slug, COALESCE(v_item.ride_park_id, v_resolved_park_id),
v_item.ride_category, v_item.ride_status,
COALESCE(v_item.manufacturer_id, v_resolved_manufacturer_id),
COALESCE(v_item.ride_model_id, v_resolved_ride_model_id),
v_item.ride_opening_date, v_item.ride_closing_date,
v_item.ride_opening_date_precision, v_item.ride_closing_date_precision,
v_item.ride_description, v_item.ride_banner_image_url, v_item.ride_banner_image_id,
v_item.ride_card_image_url, v_item.ride_card_image_id
)
RETURNING id INTO v_entity_id;
IF v_entity_id IS NOT NULL AND v_item.ride_submission_id IS NOT NULL THEN
INSERT INTO ride_technical_specifications (ride_id, specification_key, specification_value, unit, display_order)
SELECT v_entity_id, specification_key, specification_value, unit, display_order
FROM ride_technical_specifications WHERE ride_id = v_item.ride_submission_id;
INSERT INTO ride_coaster_stats (ride_id, stat_key, stat_value, unit, display_order)
SELECT v_entity_id, stat_key, stat_value, unit, display_order
FROM ride_coaster_stats WHERE ride_id = v_item.ride_submission_id;
END IF;
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
INSERT INTO companies (name, slug, description, company_type, person_type, website_url, founded_year,
founded_date, founded_date_precision, headquarters_location, logo_url,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.company_name, v_item.company_slug, v_item.company_description, v_item.company_type,
v_item.person_type, v_item.company_website_url, v_item.founded_year,
v_item.founded_date, v_item.founded_date_precision, v_item.headquarters_location, v_item.logo_url,
v_item.company_banner_image_url, v_item.company_banner_image_id,
v_item.company_card_image_url, v_item.company_card_image_id
)
RETURNING id INTO v_entity_id;
ELSIF v_item.item_type = 'ride_model' THEN
IF v_item.ride_model_manufacturer_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
INSERT INTO ride_models (name, slug, manufacturer_id, category, description,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.ride_model_name, v_item.ride_model_slug,
COALESCE(v_item.ride_model_manufacturer_id, v_resolved_manufacturer_id),
v_item.ride_model_category, v_item.ride_model_description,
v_item.ride_model_banner_image_url, v_item.ride_model_banner_image_id,
v_item.ride_model_card_image_url, v_item.ride_model_card_image_id
)
RETURNING id INTO v_entity_id;
ELSIF v_item.item_type = 'photo' THEN
INSERT INTO entity_photos (entity_id, entity_type, title, photo_submission_id)
VALUES (v_item.photo_entity_id, v_item.photo_entity_type, v_item.photo_title, v_item.photo_submission_id)
RETURNING id INTO v_entity_id;
ELSE
RAISE EXCEPTION 'Unknown item type for create: %', v_item.item_type;
END IF;
ELSIF v_item.action_type = 'update' THEN
IF v_item.entity_id IS NULL THEN
RAISE EXCEPTION 'Update action requires entity_id';
END IF;
IF v_item.item_type = 'park' THEN
-- ========================================================================
-- CRITICAL FIX: Create location with name field for updates too
-- ========================================================================
IF v_item.location_id IS NULL AND (v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL) THEN
v_location_name := COALESCE(
v_item.park_location_display_name,
v_item.park_location_name,
CONCAT_WS(', ',
NULLIF(v_item.park_location_city, ''),
NULLIF(v_item.park_location_state, ''),
NULLIF(v_item.park_location_country, '')
)
);
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (
v_location_name,
v_item.park_location_country,
v_item.park_location_state,
v_item.park_location_city,
v_item.park_location_street,
v_item.park_location_postal,
v_item.park_location_lat,
v_item.park_location_lng,
v_item.park_location_timezone
)
RETURNING id INTO v_resolved_location_id;
RAISE NOTICE '[%] Created location % (name: %) for park update',
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
END IF;
UPDATE parks SET
name = v_item.park_name, slug = v_item.park_slug, description = v_item.park_description,
park_type = v_item.park_type, status = v_item.park_status,
location_id = COALESCE(v_resolved_location_id, v_item.location_id),
operator_id = v_item.operator_id, property_owner_id = v_item.property_owner_id,
opening_date = v_item.park_opening_date, closing_date = v_item.park_closing_date,
opening_date_precision = v_item.park_opening_date_precision,
closing_date_precision = v_item.park_closing_date_precision,
website_url = v_item.park_website_url, phone = v_item.park_phone, email = v_item.park_email,
banner_image_url = v_item.park_banner_image_url, banner_image_id = v_item.park_banner_image_id,
card_image_url = v_item.park_card_image_url, card_image_id = v_item.park_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type = 'ride' THEN
UPDATE rides SET
name = v_item.ride_name, slug = v_item.ride_slug, park_id = v_item.ride_park_id,
category = v_item.ride_category, status = v_item.ride_status,
manufacturer_id = v_item.manufacturer_id, ride_model_id = v_item.ride_model_id,
opening_date = v_item.ride_opening_date, closing_date = v_item.ride_closing_date,
opening_date_precision = v_item.ride_opening_date_precision,
closing_date_precision = v_item.ride_closing_date_precision,
description = v_item.ride_description,
banner_image_url = v_item.ride_banner_image_url, banner_image_id = v_item.ride_banner_image_id,
card_image_url = v_item.ride_card_image_url, card_image_id = v_item.ride_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
UPDATE companies SET
name = v_item.company_name, slug = v_item.company_slug, description = v_item.company_description,
company_type = v_item.company_type, person_type = v_item.person_type,
website_url = v_item.company_website_url, founded_year = v_item.founded_year,
founded_date = v_item.founded_date, founded_date_precision = v_item.founded_date_precision,
headquarters_location = v_item.headquarters_location, logo_url = v_item.logo_url,
banner_image_url = v_item.company_banner_image_url, banner_image_id = v_item.company_banner_image_id,
card_image_url = v_item.company_card_image_url, card_image_id = v_item.company_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type = 'ride_model' THEN
UPDATE ride_models SET
name = v_item.ride_model_name, slug = v_item.ride_model_slug,
manufacturer_id = v_item.ride_model_manufacturer_id,
category = v_item.ride_model_category, description = v_item.ride_model_description,
banner_image_url = v_item.ride_model_banner_image_url, banner_image_id = v_item.ride_model_banner_image_id,
card_image_url = v_item.ride_model_card_image_url, card_image_id = v_item.ride_model_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type = 'photo' THEN
UPDATE entity_photos SET title = v_item.photo_title, updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSE
RAISE EXCEPTION 'Unknown item type for update: %', v_item.item_type;
END IF;
ELSE
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
END IF;
UPDATE submission_items SET approved_entity_id = v_entity_id, approved_at = now(), status = 'approved'
WHERE id = v_item.id;
v_approval_results := array_append(v_approval_results, jsonb_build_object(
'item_id', v_item.id, 'status', 'approved', 'entity_id', v_entity_id
));
v_some_approved := TRUE;
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
v_approval_results := array_append(v_approval_results, jsonb_build_object(
'item_id', v_item.id, 'status', 'failed', 'error', SQLERRM
));
v_all_approved := FALSE;
RAISE;
END;
END LOOP;
IF v_all_approved THEN
v_final_status := 'approved';
ELSIF v_some_approved THEN
v_final_status := 'partially_approved';
ELSE
v_final_status := 'rejected';
END IF;
UPDATE content_submissions SET
status = v_final_status,
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
reviewer_id = p_moderator_id,
reviewed_at = now()
WHERE id = p_submission_id;
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
v_span_id, p_trace_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_items_processed, v_final_status;
END IF;
RETURN jsonb_build_object(
'success', v_all_approved,
'status', v_final_status,
'items_processed', v_items_processed,
'results', v_approval_results,
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
);
END;
$$;
GRANT EXECUTE ON FUNCTION process_approval_transaction TO authenticated;
COMMENT ON FUNCTION process_approval_transaction IS
'✅ FIXED 2025-11-12: Now properly creates location records with name field during park approval/update.
This prevents parks from being created with NULL location_id values due to silent INSERT failures.';
-- ============================================================================
-- END OF MIGRATION
-- ============================================================================

View File

@@ -24,6 +24,7 @@ import { ResilienceProvider } from "@/components/layout/ResilienceProvider";
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
import { useVersionCheck } from "@/hooks/useVersionCheck";
import { cn } from "@/lib/utils";
import { PageTransition } from "@/components/layout/PageTransition";
// Core routes (eager-loaded for best UX)
import Index from "./pages/Index";
@@ -70,6 +71,7 @@ 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 DatabaseMaintenance = lazy(() => import("./pages/admin/DatabaseMaintenance"));
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
@@ -77,6 +79,7 @@ const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
const ApprovalHistory = lazy(() => import("./pages/admin/ApprovalHistory"));
// User routes (lazy-loaded)
const Profile = lazy(() => import("./pages/Profile"));
@@ -163,8 +166,9 @@ function AppContent(): React.JSX.Element {
<div className="min-h-screen flex flex-col">
<div className="flex-1">
<Suspense fallback={<PageLoader />}>
<RouteErrorBoundary>
<Routes>
<PageTransition>
<RouteErrorBoundary>
<Routes>
{/* Core routes - eager loaded */}
<Route path="/" element={<Index />} />
<Route path="/parks" element={<Parks />} />
@@ -384,7 +388,15 @@ function AppContent(): React.JSX.Element {
}
/>
<Route
path="/admin/error-lookup"
path="/admin/approval-history"
element={
<AdminErrorBoundary section="Approval History">
<ApprovalHistory />
</AdminErrorBoundary>
}
/>
<Route
path="/admin/error-lookup"
element={
<AdminErrorBoundary section="Error Lookup">
<ErrorLookup />
@@ -423,6 +435,14 @@ function AppContent(): React.JSX.Element {
</AdminErrorBoundary>
}
/>
<Route
path="/admin/database-maintenance"
element={
<AdminErrorBoundary section="Database Maintenance">
<DatabaseMaintenance />
</AdminErrorBoundary>
}
/>
{/* Utility routes - lazy loaded */}
<Route path="/force-logout" element={<ForceLogout />} />
@@ -434,7 +454,8 @@ function AppContent(): React.JSX.Element {
<Route path="*" element={<NotFound />} />
</Routes>
</RouteErrorBoundary>
</Suspense>
</PageTransition>
</Suspense>
</div>
<Footer />
</div>

View File

@@ -1,4 +1,4 @@
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart } from 'lucide-react';
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart, Database } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { useUserRole } from '@/hooks/useUserRole';
import { useSidebar } from '@/hooks/useSidebar';
@@ -73,6 +73,12 @@ export function AdminSidebar() {
url: '/admin/database-stats',
icon: BarChart,
},
{
title: 'Database Maintenance',
url: '/admin/database-maintenance',
icon: Database,
visible: isSuperuser, // Only superusers can access
},
{
title: 'Users',
url: '/admin/users',
@@ -134,7 +140,7 @@ export function AdminSidebar() {
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
{navItems.filter(item => item.visible !== false).map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={collapsed ? item.title : undefined}>
<NavLink

View File

@@ -0,0 +1,34 @@
import { ReactNode, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
interface PageTransitionProps {
children: ReactNode;
}
export function PageTransition({ children }: PageTransitionProps) {
const location = useLocation();
const [displayLocation, setDisplayLocation] = useState(location);
const [transitionStage, setTransitionStage] = useState<'fade-in' | 'fade-out'>('fade-in');
useEffect(() => {
if (location !== displayLocation) {
setTransitionStage('fade-out');
}
}, [location, displayLocation]);
const onAnimationEnd = () => {
if (transitionStage === 'fade-out') {
setTransitionStage('fade-in');
setDisplayLocation(location);
}
};
return (
<div
className={`${transitionStage === 'fade-out' ? 'animate-fade-out' : 'animate-fade-in'}`}
onAnimationEnd={onAnimationEnd}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
export function CompanyDetailSkeleton() {
return (
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
{/* Breadcrumb */}
<div className="h-4 bg-muted rounded w-56 mb-4" />
{/* Edit Button Area */}
<div className="flex justify-end mb-6">
<div className="h-10 bg-muted rounded w-32" />
</div>
{/* Hero Banner */}
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12 max-w-6xl mx-auto">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
<CardContent className="p-4 text-center">
<div className="h-8 bg-muted rounded w-16 mx-auto mb-2" />
<div className="h-3 bg-muted rounded w-20 mx-auto" />
</CardContent>
</Card>
))}
</div>
{/* Tabs */}
<div className="flex gap-2 border-b mb-6">
{['Overview', 'Rides', 'Models', 'Photos'].map((tab) => (
<div key={tab} className="h-10 bg-muted rounded w-20" />
))}
</div>
{/* Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Description Card */}
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-48" />
</CardHeader>
<CardContent className="space-y-3">
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-4/5" />
</CardContent>
</Card>
{/* Products Grid */}
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-40" />
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<div className="aspect-square bg-muted rounded-lg" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-2/3" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Company Info Card */}
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-40" />
</CardHeader>
<CardContent className="space-y-4">
{/* Logo */}
<div className="w-32 h-32 bg-muted rounded mx-auto mb-4" />
{/* Info Items */}
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-4 h-4 bg-muted rounded" />
<div className="flex-1">
<div className="h-4 bg-muted rounded w-24 mb-1" />
<div className="h-3 bg-muted rounded w-32" />
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
export function ParkDetailSkeleton() {
return (
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
{/* Breadcrumb */}
<div className="h-4 bg-muted rounded w-48 mb-4" />
{/* Edit Button Area */}
<div className="flex justify-end mb-6">
<div className="h-10 bg-muted rounded w-32" />
</div>
{/* Hero Banner */}
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12 max-w-6xl mx-auto">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
<CardContent className="p-4 text-center">
<div className="h-8 bg-muted rounded w-16 mx-auto mb-2" />
<div className="h-3 bg-muted rounded w-20 mx-auto" />
</CardContent>
</Card>
))}
</div>
{/* Tabs */}
<div className="flex gap-2 border-b mb-6">
{['Overview', 'Rides', 'Reviews', 'Photos', 'History'].map((tab) => (
<div key={tab} className="h-10 bg-muted rounded w-24" />
))}
</div>
{/* Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Description Card */}
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-48" />
</CardHeader>
<CardContent className="space-y-3">
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-3/4" />
</CardContent>
</Card>
{/* Featured Rides Card */}
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-40" />
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<div className="aspect-square bg-muted rounded-lg" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-3/4" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Info Card */}
<Card>
<CardHeader>
<div className="h-6 bg-muted rounded w-40" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-4 h-4 bg-muted rounded" />
<div className="flex-1">
<div className="h-4 bg-muted rounded w-24 mb-1" />
<div className="h-3 bg-muted rounded w-32" />
</div>
</div>
))}
</CardContent>
</Card>
{/* Map Card */}
<Card>
<CardContent className="p-0">
<div className="aspect-square bg-muted rounded-lg" />
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { Card, CardContent } from '@/components/ui/card';
export function RideDetailSkeleton() {
return (
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
{/* Breadcrumb */}
<div className="h-4 bg-muted rounded w-64 mb-4" />
{/* Edit Button Area */}
<div className="flex justify-end mb-6">
<div className="h-10 bg-muted rounded w-32" />
</div>
{/* Hero Banner */}
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-12">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
<CardContent className="p-4 text-center">
<div className="w-6 h-6 bg-muted rounded mx-auto mb-2" />
<div className="h-8 bg-muted rounded w-16 mx-auto mb-1" />
<div className="h-3 bg-muted rounded w-12 mx-auto" />
</CardContent>
</Card>
))}
</div>
{/* Tabs */}
<div className="flex gap-2 border-b mb-6">
{['Overview', 'Reviews', 'Photos', 'History'].map((tab) => (
<div key={tab} className="h-10 bg-muted rounded w-24" />
))}
</div>
{/* Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Description Card */}
<Card>
<CardContent className="p-6 space-y-3">
<div className="h-6 bg-muted rounded w-48 mb-4" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-5/6" />
</CardContent>
</Card>
{/* Technical Specs */}
<Card>
<CardContent className="p-6 space-y-4">
<div className="h-6 bg-muted rounded w-56 mb-4" />
<div className="grid grid-cols-2 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<div className="h-3 bg-muted rounded w-24" />
<div className="h-5 bg-muted rounded w-32" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Ride Info Card */}
<Card>
<CardContent className="p-6 space-y-4">
<div className="h-6 bg-muted rounded w-40 mb-4" />
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-4 h-4 bg-muted rounded" />
<div className="flex-1">
<div className="h-4 bg-muted rounded w-20 mb-1" />
<div className="h-3 bg-muted rounded w-28" />
</div>
</div>
))}
</CardContent>
</Card>
{/* Similar Rides */}
<Card>
<CardContent className="p-6">
<div className="h-6 bg-muted rounded w-32 mb-4" />
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<div className="w-16 h-16 bg-muted rounded" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-3/4" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { Filter, MessageSquare, FileText, Image } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, Calendar } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { EntityFilter, StatusFilter } from '@/types/moderation';
import { format } from 'date-fns';
import type { EntityFilter, StatusFilter, ApprovalDateRangeFilter } from '@/types/moderation';
interface ActiveFiltersDisplayProps {
entityFilter: EntityFilter;
statusFilter: StatusFilter;
approvalDateRange?: ApprovalDateRangeFilter;
defaultEntityFilter?: EntityFilter;
defaultStatusFilter?: StatusFilter;
}
@@ -23,12 +25,15 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
export const ActiveFiltersDisplay = ({
entityFilter,
statusFilter,
approvalDateRange,
defaultEntityFilter = 'all',
defaultStatusFilter = 'pending'
}: ActiveFiltersDisplayProps) => {
const hasDateRange = approvalDateRange && (approvalDateRange.from || approvalDateRange.to);
const hasActiveFilters =
entityFilter !== defaultEntityFilter ||
statusFilter !== defaultStatusFilter;
statusFilter !== defaultStatusFilter ||
hasDateRange;
if (!hasActiveFilters) return null;
@@ -46,6 +51,14 @@ export const ActiveFiltersDisplay = ({
{statusFilter}
</Badge>
)}
{hasDateRange && (
<Badge variant="secondary" className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{approvalDateRange.from && format(approvalDateRange.from, 'MMM d')}
{approvalDateRange.from && approvalDateRange.to && ' - '}
{approvalDateRange.to && format(approvalDateRange.to, 'MMM d')}
</Badge>
)}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { ChevronDown } from 'lucide-react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface DetailedViewCollapsibleProps {
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
fieldCount?: number;
className?: string;
staggerIndex?: number;
}
/**
* Collapsible wrapper for detailed field-by-field view sections
* Provides expand/collapse functionality with visual indicators
*/
export function DetailedViewCollapsible({
isCollapsed,
onToggle,
children,
fieldCount,
className,
staggerIndex = 0
}: DetailedViewCollapsibleProps) {
// Calculate stagger delay: 50ms per item, max 300ms
const staggerDelay = Math.min(staggerIndex * 50, 300);
return (
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
<div className={cn("mt-6 pt-6 border-t", className)}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
All Fields (Detailed View)
</span>
{fieldCount !== undefined && fieldCount > 0 && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs font-normal transition-transform duration-200 hover:scale-105"
>
{fieldCount}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground normal-case font-normal">
{isCollapsed ? 'Show' : 'Hide'}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-all duration-300 ease-out",
!isCollapsed && "rotate-180"
)}
/>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className="mt-3"
style={{
animationDelay: `${staggerDelay}ms`,
transitionDelay: `${staggerDelay}ms`
}}
>
{children}
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,321 @@
/**
* Item Approval History Component
*
* Displays detailed audit trail of approved items with exact timestamps.
* Features filtering, sorting, CSV export for compliance reporting.
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { format } from 'date-fns';
import { ExternalLink, Download, Clock, User, FileText } from 'lucide-react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { handleError } from '@/lib/errorHandler';
import type { EntityType } from '@/types/submissions';
interface ApprovalHistoryItem {
item_id: string;
submission_id: string;
item_type: string;
action_type: string;
status: string;
approved_at: string;
approved_entity_id: string;
created_at: string;
approval_time_seconds: number;
submission_type: string;
submitter_username: string | null;
submitter_display_name: string | null;
submitter_avatar_url: string | null;
approver_username: string | null;
approver_display_name: string | null;
approver_avatar_url: string | null;
entity_slug: string | null;
entity_name: string | null;
}
interface ItemApprovalHistoryProps {
submissionId?: string;
dateRange?: { from: Date; to: Date };
itemType?: EntityType;
limit?: number;
embedded?: boolean;
}
const getApprovalSpeed = (seconds: number) => {
const hours = seconds / 3600;
if (hours < 1) return { label: 'Fast', variant: 'default' as const, color: 'text-green-600 dark:text-green-400' };
if (hours < 24) return { label: 'Normal', variant: 'secondary' as const, color: 'text-blue-600 dark:text-blue-400' };
return { label: 'Slow', variant: 'destructive' as const, color: 'text-orange-600 dark:text-orange-400' };
};
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 48) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const getEntityPath = (itemType: string, slug: string | null) => {
if (!slug) return null;
switch (itemType) {
case 'park': return `/parks/${slug}/`;
case 'ride': return `/rides/${slug}`; // Need park slug ideally
case 'manufacturer':
case 'designer':
case 'operator':
return `/companies/${slug}/`;
case 'ride_model': return `/models/${slug}/`;
default: return null;
}
};
export const ItemApprovalHistory = ({
submissionId,
dateRange,
itemType,
limit = 100,
embedded = false
}: ItemApprovalHistoryProps) => {
const [sortField, setSortField] = useState<'approved_at' | 'approval_time_seconds'>('approved_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const { data: history, isLoading, error } = useQuery({
queryKey: ['approval-history', { submissionId, dateRange, itemType, limit }],
queryFn: async () => {
try {
const { data, error } = await supabase.rpc('get_approval_history', {
p_item_type: itemType || undefined,
p_approver_id: undefined,
p_from_date: dateRange?.from?.toISOString() || undefined,
p_to_date: dateRange?.to?.toISOString() || undefined,
p_limit: limit,
p_offset: 0
});
if (error) throw error;
// Client-side filter by submission_id if provided
let filtered = data as ApprovalHistoryItem[];
if (submissionId) {
filtered = filtered.filter(item => item.submission_id === submissionId);
}
return filtered;
} catch (err: unknown) {
handleError(err, { action: 'fetch_approval_history' });
throw err;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const sortedHistory = history ? [...history].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortDirection === 'asc' ? comparison : -comparison;
}) : [];
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('desc');
}
};
const exportToCSV = () => {
if (!history || history.length === 0) return;
const headers = [
'Timestamp',
'Item Type',
'Action',
'Entity Name',
'Submitter',
'Approver',
'Time to Approve (hours)',
'Submission ID',
'Item ID'
];
const rows = history.map(item => [
format(new Date(item.approved_at), 'yyyy-MM-dd HH:mm:ss'),
item.item_type,
item.action_type,
item.entity_name || 'N/A',
item.submitter_display_name || item.submitter_username || 'Unknown',
item.approver_display_name || item.approver_username || 'Unknown',
(item.approval_time_seconds / 3600).toFixed(2),
item.submission_id,
item.item_id
]);
const csv = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `approval-history-${format(new Date(), 'yyyy-MM-dd')}.csv`;
link.click();
URL.revokeObjectURL(url);
};
if (error) {
return (
<Card className={embedded ? '' : 'mt-6'}>
<CardContent className="pt-6">
<p className="text-sm text-destructive">Failed to load approval history. Please try again.</p>
</CardContent>
</Card>
);
}
const content = (
<>
{!embedded && (
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Item Approval History</CardTitle>
<CardDescription>Detailed audit trail of approved submissions</CardDescription>
</div>
{sortedHistory.length > 0 && (
<Button onClick={exportToCSV} variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
)}
</div>
</CardHeader>
)}
<CardContent className={embedded ? 'p-0' : ''}>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : sortedHistory.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No approval history found</p>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('approved_at')}
>
Approved At {sortField === 'approved_at' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead>Type</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Submitter</TableHead>
<TableHead>Approver</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort('approval_time_seconds')}
>
Time to Approve {sortField === 'approval_time_seconds' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedHistory.map((item) => {
const speed = getApprovalSpeed(item.approval_time_seconds);
const entityPath = getEntityPath(item.item_type, item.entity_slug);
return (
<TableRow key={item.item_id}>
<TableCell className="font-mono text-xs">
<div className="flex flex-col">
<span>{format(new Date(item.approved_at), 'MMM d, yyyy')}</span>
<span className="text-muted-foreground">{format(new Date(item.approved_at), 'HH:mm:ss')}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{item.item_type}
</Badge>
</TableCell>
<TableCell>
{item.entity_name ? (
<div className="flex items-center gap-2">
<span className="font-medium">{item.entity_name}</span>
{entityPath && (
<a
href={entityPath}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
) : (
<span className="text-muted-foreground text-sm">N/A</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={item.submitter_avatar_url || undefined} />
<AvatarFallback className="text-xs">
{(item.submitter_display_name || item.submitter_username || 'U')[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm">{item.submitter_display_name || item.submitter_username || 'Unknown'}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={item.approver_avatar_url || undefined} />
<AvatarFallback className="text-xs">
{(item.approver_display_name || item.approver_username || 'M')[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm">{item.approver_display_name || item.approver_username || 'Unknown'}</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Clock className={`w-4 h-4 ${speed.color}`} />
<span className="font-mono text-sm">{formatDuration(item.approval_time_seconds)}</span>
<Badge variant={speed.variant} className="ml-1">
{speed.label}
</Badge>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</>
);
return embedded ? content : <Card className="mt-6">{content}</Card>;
};

View File

@@ -0,0 +1,125 @@
import { memo } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, User } from 'lucide-react';
import type { SubmissionItem } from '@/types/moderation';
interface ItemLevelApprovalHistoryProps {
items: SubmissionItem[];
reviewerProfile?: {
user_id: string;
username: string;
display_name?: string | null;
avatar_url?: string | null;
} | null;
}
export const ItemLevelApprovalHistory = memo(({
items,
reviewerProfile,
}: ItemLevelApprovalHistoryProps) => {
// Filter to only approved items with timestamps
const approvedItems = items.filter(
item => item.status === 'approved' && (item as any).approved_at
);
if (approvedItems.length === 0) {
return null;
}
// Sort by approval time (newest first)
const sortedItems = [...approvedItems].sort((a, b) => {
const timeA = new Date((a as any).approved_at).getTime();
const timeB = new Date((b as any).approved_at).getTime();
return timeB - timeA;
});
// Helper to get item display name
const getItemName = (item: SubmissionItem): string => {
const entityData = item.entity_data || item.item_data;
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
return String(entityData.name);
}
return `${item.item_type} #${item.order_index}`;
};
// Helper to get action label
const getActionLabel = (actionType: string): string => {
switch (actionType) {
case 'create': return 'Created';
case 'edit': return 'Edited';
case 'delete': return 'Deleted';
default: return 'Modified';
}
};
return (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5" />
Item Approvals
</div>
<div className="space-y-2">
{sortedItems.map((item) => {
const approvedAt = (item as any).approved_at;
const itemName = getItemName(item);
const actionLabel = getActionLabel(item.action_type);
return (
<div
key={item.id}
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
>
{/* Approval Icon */}
<div className="flex-shrink-0 mt-0.5">
<CheckCircle2 className="h-4 w-4 text-success" />
</div>
{/* Item Info */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground truncate">
{itemName}
</span>
<Badge variant="outline" className="text-xs">
{actionLabel}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{item.item_type}
</Badge>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
</span>
</div>
{/* Reviewer Info */}
{reviewerProfile && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar className="h-5 w-5">
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
<AvatarFallback className="text-[10px]">
<User className="h-3 w-3" />
</AvatarFallback>
</Avatar>
<span>
Approved by{' '}
<span className="font-medium text-foreground">
{reviewerProfile.display_name || reviewerProfile.username}
</span>
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
});
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';

View File

@@ -501,11 +501,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
activeEntityFilter={queueManager.filters.entityFilter}
activeStatusFilter={queueManager.filters.statusFilter}
sortConfig={queueManager.filters.sortConfig}
activeTab={queueManager.filters.activeTab}
approvalDateRange={queueManager.filters.approvalDateRange}
isMobile={isMobile ?? false}
isLoading={queueManager.loadingState === 'loading'}
onEntityFilterChange={queueManager.filters.setEntityFilter}
onStatusFilterChange={queueManager.filters.setStatusFilter}
onSortChange={queueManager.filters.setSortConfig}
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
onClearFilters={queueManager.filters.clearFilters}
showClearButton={queueManager.filters.hasActiveFilters}
onRefresh={queueManager.refresh}
@@ -517,6 +520,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
<ActiveFiltersDisplay
entityFilter={queueManager.filters.entityFilter}
statusFilter={queueManager.filters.statusFilter}
approvalDateRange={queueManager.filters.approvalDateRange}
/>
)}

View File

@@ -1,23 +1,29 @@
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar, Maximize2, Minimize2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { RefreshButton } from '@/components/ui/refresh-button';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
interface QueueFiltersProps {
activeEntityFilter: EntityFilter;
activeStatusFilter: StatusFilter;
sortConfig: SortConfig;
activeTab: QueueTab;
approvalDateRange: ApprovalDateRangeFilter;
isMobile: boolean;
isLoading?: boolean;
onEntityFilterChange: (filter: EntityFilter) => void;
onStatusFilterChange: (filter: StatusFilter) => void;
onSortChange: (config: SortConfig) => void;
onApprovalDateRangeChange: (range: ApprovalDateRangeFilter) => void;
onClearFilters: () => void;
showClearButton: boolean;
onRefresh?: () => void;
@@ -37,22 +43,27 @@ export const QueueFilters = ({
activeEntityFilter,
activeStatusFilter,
sortConfig,
activeTab,
approvalDateRange,
isMobile,
isLoading = false,
onEntityFilterChange,
onStatusFilterChange,
onSortChange,
onApprovalDateRangeChange,
onClearFilters,
showClearButton,
onRefresh,
isRefreshing = false
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
const { isCollapsed: detailsCollapsed, toggle: toggleDetails } = useDetailedViewState();
// Count active filters
const activeFilterCount = [
activeEntityFilter !== 'all' ? 1 : 0,
activeStatusFilter !== 'all' ? 1 : 0,
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
return (
@@ -68,14 +79,51 @@ export const QueueFilters = ({
</Badge>
)}
</div>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
<div className="flex items-center gap-2">
{/* Global toggle for detailed views */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={toggleDetails}
className="h-8 gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{detailsCollapsed ? (
<>
<Maximize2 className="h-3.5 w-3.5" />
{!isMobile && <span>Expand All</span>}
</>
) : (
<>
<Minimize2 className="h-3.5 w-3.5" />
{!isMobile && <span>Collapse All</span>}
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="text-xs">
{detailsCollapsed
? "Show detailed field-by-field view for all items in the queue"
: "Hide detailed field-by-field view for all items in the queue"}
</p>
<p className="text-xs text-muted-foreground mt-1">
This preference is saved to your account
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
</div>
</div>
<CollapsibleContent className="space-y-4">
@@ -164,6 +212,21 @@ export const QueueFilters = ({
isMobile={isMobile}
isLoading={isLoading}
/>
{/* Approval Date Range Filter - Only show on archive tab */}
{activeTab === 'archive' && (
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[280px]'}`}>
<FilterDateRangePicker
label="Approved Between"
fromDate={approvalDateRange.from}
toDate={approvalDateRange.to}
onFromChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, from: date || null })}
onToChange={(date) => onApprovalDateRangeChange({ ...approvalDateRange, to: date || null })}
fromPlaceholder="Start Date"
toPlaceholder="End Date"
/>
</div>
)}
</div>
{/* Clear Filters & Apply Buttons (mobile only) */}

View File

@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
import { AuditTrailViewer } from './AuditTrailViewer';
import { RawDataViewer } from './RawDataViewer';
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
interface QueueItemProps {
item: ModerationItem;
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
{item.type === 'content_submission' && (
<div className="mt-6 space-y-4">
<SubmissionMetadataPanel item={item} />
{/* Item-level approval history */}
{item.submission_items && item.submission_items.length > 0 && (
<ItemLevelApprovalHistory
items={item.submission_items}
reviewerProfile={item.reviewer_profile}
/>
)}
<AuditTrailViewer submissionId={item.id} />
</div>
)}

View File

@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
import type { TimelineSubmissionData } from '@/types/timeline';
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
interface SubmissionItemsListProps {
submissionId: string;
@@ -34,11 +36,18 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isCollapsed, toggle } = useDetailedViewState();
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
// Helper function to count non-null fields in entity data
const countFields = (data: any): number => {
if (!data || typeof data !== 'object') return 0;
return Object.values(data).filter(value => value !== null && value !== undefined).length;
};
const fetchSubmissionItems = async () => {
try {
// Only show skeleton on initial load, show refreshing indicator on refresh
@@ -126,7 +135,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
}
// Render item with appropriate display component
const renderItem = (item: SubmissionItemData) => {
const renderItem = (item: SubmissionItemData, index: number = 0) => {
// SubmissionItemData from submissions.ts has item_data property
const entityData = item.item_data;
const actionType = item.action_type || 'create';
@@ -188,17 +197,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as ParkSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -211,17 +222,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as RideSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -234,17 +247,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as CompanySubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -257,17 +272,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as RideModelSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -280,17 +297,19 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
data={entityData as unknown as TimelineSubmissionData}
actionType={actionType}
/>
<div className="mt-6 pt-6 border-t">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
All Fields (Detailed View)
</div>
<DetailedViewCollapsible
isCollapsed={isCollapsed}
onToggle={toggle}
fieldCount={countFields(entityData)}
staggerIndex={index}
>
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={showImages}
submissionId={submissionId}
/>
</div>
</DetailedViewCollapsible>
</>
);
}
@@ -320,9 +339,9 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
)}
{/* Show regular submission items */}
{items.map((item) => (
{items.map((item, index) => (
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
{renderItem(item)}
{renderItem(item, index)}
</div>
))}

View File

@@ -0,0 +1,87 @@
import { Link } from 'react-router-dom';
import { Home } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
interface BreadcrumbSegment {
label: string;
href?: string;
showPreview?: boolean;
previewType?: 'park' | 'company';
previewSlug?: string;
}
interface EntityBreadcrumbProps {
segments: BreadcrumbSegment[];
className?: string;
}
export function EntityBreadcrumb({ segments, className }: EntityBreadcrumbProps) {
return (
<Breadcrumb className={className}>
<BreadcrumbList>
{/* Home link */}
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/" className="flex items-center gap-1 hover:text-primary transition-colors">
<Home className="w-3.5 h-3.5" />
<span>Home</span>
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{segments.map((segment, index) => {
const isLast = index === segments.length - 1;
return (
<BreadcrumbItem key={index}>
<BreadcrumbSeparator />
{isLast ? (
<BreadcrumbPage>{segment.label}</BreadcrumbPage>
) : segment.showPreview && segment.previewSlug ? (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<BreadcrumbLink asChild>
<Link
to={segment.href || '#'}
className="hover:text-primary transition-colors"
>
{segment.label}
</Link>
</BreadcrumbLink>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="w-auto">
{segment.previewType === 'park' && (
<ParkPreviewCard slug={segment.previewSlug} />
)}
{segment.previewType === 'company' && (
<CompanyPreviewCard slug={segment.previewSlug} />
)}
</HoverCardContent>
</HoverCard>
) : (
<BreadcrumbLink asChild>
<Link
to={segment.href || '#'}
className="hover:text-primary transition-colors"
>
{segment.label}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,80 @@
import { Building2, MapPin, Calendar } from 'lucide-react';
import { useCompanyPreview } from '@/hooks/preview/useCompanyPreview';
import { Badge } from '@/components/ui/badge';
interface CompanyPreviewCardProps {
slug: string;
}
export function CompanyPreviewCard({ slug }: CompanyPreviewCardProps) {
const { data: company, isLoading } = useCompanyPreview(slug);
if (isLoading) {
return (
<div className="w-80">
<div className="animate-pulse space-y-3">
<div className="h-16 bg-muted rounded" />
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
</div>
);
}
if (!company) {
return (
<div className="w-80 p-4 text-center text-muted-foreground">
Company not found
</div>
);
}
const formatCompanyType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
return (
<div className="w-80 space-y-3">
{/* Header with logo */}
<div className="flex items-start gap-3">
{company.logo_url ? (
<img
src={company.logo_url}
alt={company.name}
className="w-12 h-12 object-contain rounded"
/>
) : (
<div className="w-12 h-12 bg-muted rounded flex items-center justify-center">
<Building2 className="w-6 h-6 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base line-clamp-1">{company.name}</h3>
<Badge variant="secondary" className="mt-1">
{formatCompanyType(company.company_type)}
</Badge>
</div>
</div>
{/* Location and Founded */}
<div className="space-y-2 text-sm">
{company.headquarters_location && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="w-4 h-4 flex-shrink-0" />
<span className="line-clamp-1">{company.headquarters_location}</span>
</div>
)}
{company.founded_year && (
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>Founded {company.founded_year}</span>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Click to view full details
</p>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { MapPin, Star, FerrisWheel, Zap } from 'lucide-react';
import { useParkPreview } from '@/hooks/preview/useParkPreview';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
interface ParkPreviewCardProps {
slug: string;
}
export function ParkPreviewCard({ slug }: ParkPreviewCardProps) {
const { data: park, isLoading } = useParkPreview(slug);
if (isLoading) {
return (
<div className="w-80">
<div className="animate-pulse space-y-3">
<div className="h-32 bg-muted rounded" />
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
</div>
);
}
if (!park) {
return (
<div className="w-80 p-4 text-center text-muted-foreground">
Park not found
</div>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'operating':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'seasonal':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'under_construction':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-red-500/20 text-red-400 border-red-500/30';
}
};
const formatParkType = (type: string) => {
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
};
return (
<div className="w-80 space-y-3">
{/* Image */}
{park.card_image_url && (
<div className="aspect-video rounded-lg overflow-hidden bg-muted">
<img
src={park.card_image_url}
alt={park.name}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Header */}
<div>
<h3 className="font-semibold text-base line-clamp-1 mb-2">{park.name}</h3>
<div className="flex items-center gap-2 flex-wrap">
<Badge className={`${getStatusColor(park.status)} border text-xs`}>
{park.status.replace('_', ' ').toUpperCase()}
</Badge>
<Badge variant="outline" className="text-xs">
{formatParkType(park.park_type)}
</Badge>
</div>
</div>
{/* Location */}
{park.location && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="w-4 h-4 flex-shrink-0" />
<span className="line-clamp-1">
{[park.location.city, park.location.state_province, park.location.country]
.filter(Boolean)
.join(', ')}
</span>
</div>
)}
<Separator />
{/* Stats */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<FerrisWheel className="w-4 h-4 text-primary" />
<span className="font-medium">{park.ride_count || 0}</span>
<span className="text-muted-foreground">rides</span>
</div>
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" />
<span className="font-medium">{park.coaster_count || 0}</span>
<span className="text-muted-foreground">coasters</span>
</div>
{park.average_rating && park.average_rating > 0 && (
<div className="flex items-center gap-2 col-span-2">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
<span className="font-medium">{park.average_rating.toFixed(1)}</span>
<span className="text-muted-foreground">({park.review_count} reviews)</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -4,6 +4,9 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Ride } from '@/types/database';
import { cn } from '@/lib/utils';
import { Link } from 'react-router-dom';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
interface RideListViewProps {
rides: Ride[];
@@ -115,10 +118,19 @@ export function RideListView({ rides, onRideClick }: RideListViewProps) {
{formatCategory(ride.category)}
</Badge>
{ride.manufacturer && (
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300">
<Factory className="w-3 h-3 mr-1" />
{ride.manufacturer.name}
</Badge>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300 hover:bg-accent/10 cursor-pointer">
<Factory className="w-3 h-3 mr-1" />
{ride.manufacturer.name}
</Badge>
</Link>
</HoverCardTrigger>
<HoverCardContent side="top" className="w-auto">
<CompanyPreviewCard slug={ride.manufacturer.slug} />
</HoverCardContent>
</HoverCard>
)}
</div>
</div>

View File

@@ -1,9 +1,30 @@
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Content>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.Content
ref={ref}
className={cn(
"overflow-hidden transition-all duration-300 ease-out",
"data-[state=closed]:animate-accordion-up",
"data-[state=open]:animate-accordion-down",
className
)}
{...props}
>
<div className="animate-fade-in">
{children}
</div>
</CollapsiblePrimitive.Content>
));
CollapsibleContent.displayName = "CollapsibleContent";
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,30 +1,70 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { AlertTriangle } from 'lucide-react';
import { AlertTriangle, Plus, Minus, Edit } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useEntityVersions } from '@/hooks/useEntityVersions';
import type { EntityType } from '@/types/versioning';
interface RollbackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
versionId: string;
entityType: string;
entityType: EntityType;
entityId: string;
entityName: string;
onRollback: (reason: string) => Promise<void>;
}
interface VersionDiff {
[fieldName: string]: {
from: unknown;
to: unknown;
};
}
export function RollbackDialog({
open,
onOpenChange,
versionId,
entityType,
entityId,
entityName,
onRollback,
}: RollbackDialogProps) {
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const [diff, setDiff] = useState<VersionDiff | null>(null);
const [diffLoading, setDiffLoading] = useState(false);
const { versions, compareVersions } = useEntityVersions(entityType, entityId);
const currentVersion = versions[0]; // Most recent version
// Fetch diff when dialog opens
useEffect(() => {
const loadDiff = async () => {
if (open && versionId && currentVersion?.version_id) {
setDiffLoading(true);
try {
const result = await compareVersions(versionId, currentVersion.version_id);
setDiff(result as VersionDiff);
} catch (error) {
console.error('Failed to load diff:', error);
setDiff(null);
} finally {
setDiffLoading(false);
}
}
};
loadDiff();
}, [open, versionId, currentVersion?.version_id, compareVersions]);
const handleRollback = async () => {
if (!reason.trim()) return;
@@ -33,15 +73,32 @@ export function RollbackDialog({
try {
await onRollback(reason);
setReason('');
setDiff(null);
onOpenChange(false);
} finally {
setLoading(false);
}
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
const formatFieldName = (fieldName: string): string => {
return fieldName
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const changedFieldCount = diff ? Object.keys(diff).length : 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Restore Previous Version (Moderator Action)</DialogTitle>
<DialogDescription>
@@ -56,6 +113,100 @@ export function RollbackDialog({
</AlertDescription>
</Alert>
{/* Preview Changes Section */}
<Accordion type="single" collapsible defaultValue="preview" className="border rounded-lg">
<AccordionItem value="preview" className="border-none">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-2">
<span className="font-medium">Preview Changes</span>
{changedFieldCount > 0 && (
<Badge variant="outline" className="bg-blue-500/10 text-blue-700 dark:text-blue-400">
{changedFieldCount} field{changedFieldCount !== 1 ? 's' : ''} will change
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<ScrollArea className="h-[300px] pr-4">
{diffLoading ? (
<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>
) : diff && Object.keys(diff).length > 0 ? (
<div className="space-y-3">
{Object.entries(diff).map(([fieldName, changes]: [string, any]) => {
const isAdded = changes.from === null;
const isRemoved = changes.to === null;
const isModified = changes.from !== null && changes.to !== null;
return (
<div key={fieldName} className="border rounded-md p-3 space-y-2">
<div className="flex items-center gap-2">
{isAdded && <Plus className="h-4 w-4 text-green-500" />}
{isRemoved && <Minus className="h-4 w-4 text-red-500" />}
{isModified && <Edit className="h-4 w-4 text-blue-500" />}
<span className="font-medium text-sm">{formatFieldName(fieldName)}</span>
<Badge
variant="outline"
className={
isAdded
? 'bg-green-500/10 text-green-700 dark:text-green-400'
: isRemoved
? 'bg-red-500/10 text-red-700 dark:text-red-400'
: 'bg-blue-500/10 text-blue-700 dark:text-blue-400'
}
>
{isAdded ? 'Added' : isRemoved ? 'Removed' : 'Modified'}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3">
{/* Current value (will be replaced) */}
<div className="space-y-1">
<div className="text-xs text-muted-foreground font-medium">Current</div>
<div
className={`p-2 rounded-md font-mono text-xs whitespace-pre-wrap ${
isRemoved
? 'bg-red-500/10 text-red-700 dark:text-red-400 line-through'
: 'bg-muted'
}`}
>
{formatValue(changes.to)}
</div>
</div>
{/* Restored value */}
<div className="space-y-1">
<div className="text-xs text-muted-foreground font-medium">After Restore</div>
<div
className={`p-2 rounded-md font-mono text-xs whitespace-pre-wrap ${
isAdded
? 'bg-green-500/10 text-green-700 dark:text-green-400 font-semibold'
: isModified
? 'bg-blue-500/10 text-blue-700 dark:text-blue-400 font-semibold'
: 'bg-muted'
}`}
>
{formatValue(changes.from)}
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No differences found</p>
</div>
)}
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="space-y-2">
<Label htmlFor="rollback-reason">Reason for rollback *</Label>
<Textarea

View File

@@ -1,8 +1,7 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { History, Clock } from 'lucide-react';
import { History } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { EntityVersionHistory } from './EntityVersionHistory';
import { useEntityVersions } from '@/hooks/useEntityVersions';
@@ -43,7 +42,7 @@ export function VersionIndicator({
>
<History className="h-4 w-4" />
<span className="text-xs text-muted-foreground">
v{currentVersion.version_number}
History
</span>
</Button>
@@ -66,10 +65,6 @@ export function VersionIndicator({
return (
<>
<div className="flex items-center gap-3">
<Badge variant="outline" className="gap-1.5">
<Clock className="h-3 w-3" />
Version {currentVersion.version_number}
</Badge>
<span className="text-sm text-muted-foreground">
Last edited {timeAgo}
</span>

View File

@@ -0,0 +1,135 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { queryKeys } from '@/lib/queryKeys';
import { toast } from 'sonner';
export interface MaintenanceTable {
table_name: string;
row_count: number;
table_size: string;
indexes_size: string;
total_size: string;
}
export interface MaintenanceResult {
table_name: string;
operation: string;
started_at: string;
completed_at: string;
duration_ms: number;
success: boolean;
error?: string;
}
export function useMaintenanceTables() {
return useQuery({
queryKey: queryKeys.admin.maintenanceTables(),
queryFn: async () => {
const { data, error } = await supabase.rpc('get_maintenance_tables');
if (error) throw error;
return data as unknown as MaintenanceTable[];
},
staleTime: 2 * 60 * 1000, // 2 minutes
});
}
export function useVacuumTable() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tableName: string) => {
const { data, error } = await supabase.rpc('run_vacuum_table', {
table_name: tableName,
});
if (error) throw error;
return data as unknown as MaintenanceResult;
},
onSuccess: (result) => {
if (result.success) {
toast.success(`Vacuum completed on ${result.table_name}`, {
description: `Duration: ${Math.round(result.duration_ms)}ms`,
});
} else {
toast.error(`Vacuum failed on ${result.table_name}`, {
description: result.error,
});
}
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
},
onError: (error: Error) => {
toast.error('Vacuum operation failed', {
description: error.message,
});
},
});
}
export function useAnalyzeTable() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tableName: string) => {
const { data, error } = await supabase.rpc('run_analyze_table', {
table_name: tableName,
});
if (error) throw error;
return data as unknown as MaintenanceResult;
},
onSuccess: (result) => {
if (result.success) {
toast.success(`Analyze completed on ${result.table_name}`, {
description: `Duration: ${Math.round(result.duration_ms)}ms`,
});
} else {
toast.error(`Analyze failed on ${result.table_name}`, {
description: result.error,
});
}
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
},
onError: (error: Error) => {
toast.error('Analyze operation failed', {
description: error.message,
});
},
});
}
export function useReindexTable() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tableName: string) => {
const { data, error } = await supabase.rpc('run_reindex_table', {
table_name: tableName,
});
if (error) throw error;
return data as unknown as MaintenanceResult;
},
onSuccess: (result) => {
if (result.success) {
toast.success(`Reindex completed on ${result.table_name}`, {
description: `Duration: ${Math.round(result.duration_ms)}ms`,
});
} else {
toast.error(`Reindex failed on ${result.table_name}`, {
description: result.error,
});
}
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
},
onError: (error: Error) => {
toast.error('Reindex operation failed', {
description: error.message,
});
},
});
}

View File

@@ -86,7 +86,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
itemIds: string[],
userId?: string,
maxConflictRetries: number = 3,
timeoutMs: number = 30000
timeoutMs: number = 60000 // Increased from 30s to 60s
): Promise<{
data: T | null;
error: any;
@@ -337,7 +337,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
submissionItems.map((i) => i.id),
config.user?.id,
3, // Max 3 conflict retries
30000 // 30s timeout
60000 // 60s timeout (increased for slow queries)
);
// Log retry attempts
@@ -393,7 +393,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
submissionItems.map((i) => i.id),
config.user?.id,
3, // Max 3 conflict retries
30000 // 30s timeout
60000 // 60s timeout (increased for slow queries)
);
// Log retry attempts

View File

@@ -12,7 +12,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { logger } from '@/lib/logger';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField } from '@/types/moderation';
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField, ApprovalDateRangeFilter } from '@/types/moderation';
import * as storage from '@/lib/localStorage';
export interface ModerationFiltersConfig {
@@ -36,6 +36,9 @@ export interface ModerationFiltersConfig {
/** Initial sort configuration */
initialSortConfig?: SortConfig;
/** Initial approval date range filter */
initialApprovalDateRange?: ApprovalDateRangeFilter;
}
export interface ModerationFilters {
@@ -87,6 +90,15 @@ export interface ModerationFilters {
/** Reset sort to default */
resetSort: () => void;
/** Approval date range filter (immediate) */
approvalDateRange: ApprovalDateRangeFilter;
/** Debounced approval date range (use this for queries) */
debouncedApprovalDateRange: ApprovalDateRangeFilter;
/** Set approval date range */
setApprovalDateRange: (range: ApprovalDateRangeFilter) => void;
/** Reset pagination to page 1 (callback) */
onFilterChange?: () => void;
}
@@ -121,6 +133,7 @@ export function useModerationFilters(
persist = true,
storageKey = 'moderationQueue_filters',
initialSortConfig = { field: 'created_at', direction: 'asc' },
initialApprovalDateRange = { from: null, to: null },
onFilterChange,
} = config;
@@ -174,6 +187,9 @@ export function useModerationFilters(
// Sort state
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
// Approval date range state
const [approvalDateRange, setApprovalDateRangeState] = useState<ApprovalDateRangeFilter>(initialApprovalDateRange);
// Debounced filters for API calls
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
@@ -181,6 +197,9 @@ export function useModerationFilters(
// Debounced sort (0ms for immediate feedback)
const debouncedSortConfig = useDebounce(sortConfig, 0);
// Debounced approval date range
const debouncedApprovalDateRange = useDebounce(approvalDateRange, debounceDelay);
// Persist filters to localStorage
useEffect(() => {
@@ -246,6 +265,13 @@ export function useModerationFilters(
const resetSort = useCallback(() => {
setSortConfigState(initialSortConfig);
}, [initialSortConfig]);
// Set approval date range with logging and pagination reset
const setApprovalDateRange = useCallback((range: ApprovalDateRangeFilter) => {
logger.log('🔍 Approval date range changed:', range);
setApprovalDateRangeState(range);
onFilterChange?.();
}, [onFilterChange]);
// Clear all filters
const clearFilters = useCallback(() => {
@@ -254,7 +280,8 @@ export function useModerationFilters(
setStatusFilterState(initialStatusFilter);
setActiveTabState(initialTab);
setSortConfigState(initialSortConfig);
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig]);
setApprovalDateRangeState(initialApprovalDateRange);
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig, initialApprovalDateRange]);
// Check if non-default filters are active
const hasActiveFilters =
@@ -262,7 +289,9 @@ export function useModerationFilters(
statusFilter !== initialStatusFilter ||
activeTab !== initialTab ||
sortConfig.field !== initialSortConfig.field ||
sortConfig.direction !== initialSortConfig.direction;
sortConfig.direction !== initialSortConfig.direction ||
approvalDateRange.from !== null ||
approvalDateRange.to !== null;
// Return without useMemo wrapper (OPTIMIZED)
return {
@@ -282,6 +311,9 @@ export function useModerationFilters(
sortBy,
toggleSortDirection,
resetSort,
approvalDateRange,
debouncedApprovalDateRange,
setApprovalDateRange,
onFilterChange,
};
}

View File

@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
sortConfig: filters.debouncedSortConfig,
approvalDateRange: filters.debouncedApprovalDateRange,
enabled: !!user,
});

View File

@@ -98,6 +98,12 @@ export interface UseQueueQueryConfig {
direction: SortDirection;
};
/** Approval date range filter */
approvalDateRange?: {
from: Date | null;
to: Date | null;
};
/** Whether query is enabled (defaults to true) */
enabled?: boolean;
}
@@ -145,6 +151,7 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
currentPage: config.currentPage,
pageSize: config.pageSize,
sortConfig: config.sortConfig,
approvalDateRange: config.approvalDateRange,
};
// Create stable query key (TanStack Query uses this for caching/deduplication)
@@ -161,6 +168,8 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
config.pageSize,
config.sortConfig.field,
config.sortConfig.direction,
config.approvalDateRange?.from?.toISOString(),
config.approvalDateRange?.to?.toISOString(),
];
// Execute query

View File

@@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch company preview data for hover cards
*/
export function useCompanyPreview(slug: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.companies.detail(slug || ''),
queryFn: async () => {
if (!slug) throw new Error('Slug is required');
const { data, error } = await supabase
.from('companies')
.select(`
id,
name,
slug,
company_type,
person_type,
headquarters_location,
founded_year,
logo_url
`)
.eq('slug', slug)
.maybeSingle();
if (error) throw error;
return data;
},
enabled: enabled && !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});
}

View File

@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch park preview data for hover cards
*/
export function useParkPreview(slug: string | undefined, enabled = true) {
return useQuery({
queryKey: queryKeys.parks.detail(slug || ''),
queryFn: async () => {
if (!slug) throw new Error('Slug is required');
const { data, error } = await supabase
.from('parks')
.select(`
id,
name,
slug,
park_type,
status,
card_image_url,
ride_count,
coaster_count,
average_rating,
review_count,
location:locations(city, state_province, country)
`)
.eq('slug', slug)
.maybeSingle();
if (error) throw error;
return data;
},
enabled: enabled && !!slug,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});
}

View File

@@ -6,12 +6,15 @@
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocation } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useEffect } from 'react';
import type { CompletenessAnalysis, CompletenessFilters } from '@/types/data-completeness';
import { handleError } from '@/lib/errorHandler';
export function useDataCompleteness(filters: CompletenessFilters = {}) {
const location = useLocation();
const isAdminPage = location.pathname.startsWith('/admin');
const queryClient = useQueryClient();
const query = useQuery({
@@ -40,6 +43,7 @@ export function useDataCompleteness(filters: CompletenessFilters = {}) {
throw error;
}
},
enabled: isAdminPage, // Only run on admin pages
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
refetchOnWindowFocus: false,
});

View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { logger } from '@/lib/logger';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/lib/supabaseClient';
import { handleNonCriticalError } from '@/lib/errorHandler';
import type { Json } from '@/integrations/supabase/types';
const STORAGE_KEY = 'detailed-view-collapsed';
interface ModerationPreferences {
detailed_view_collapsed: boolean;
}
interface UseDetailedViewStateReturn {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (value: boolean) => void;
loading: boolean;
}
/**
* Hook to manage detailed view collapsed/expanded state
* Persists to database for authenticated users, localStorage for guests
* Defaults to collapsed to reduce visual clutter
*/
export function useDetailedViewState(): UseDetailedViewStateReturn {
const { user } = useAuth();
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const [loading, setLoading] = useState(true);
// Load preferences on mount
useEffect(() => {
loadPreferences();
}, [user]);
const loadPreferences = async () => {
try {
if (user) {
// Load from database for authenticated users
const { data, error } = await supabase
.from('user_preferences')
.select('moderation_preferences')
.eq('user_id', user.id)
.maybeSingle();
if (error && error.code !== 'PGRST116') {
handleNonCriticalError(error, {
action: 'Load moderation preferences',
userId: user.id,
});
}
// Type assertion needed until Supabase regenerates types after migration
const preferences = (data as any)?.moderation_preferences;
if (preferences) {
const prefs = preferences as ModerationPreferences;
setIsCollapsed(prefs.detailed_view_collapsed ?? true);
}
} else {
// Load from localStorage for guests
try {
const stored = localStorage.getItem(STORAGE_KEY);
setIsCollapsed(stored ? JSON.parse(stored) : true);
} catch (error) {
logger.warn('Error reading detailed view state from localStorage', { error });
}
}
} catch (error) {
logger.warn('Error loading detailed view preferences', { error });
} finally {
setLoading(false);
}
};
const savePreferences = async (collapsed: boolean) => {
try {
if (user) {
// Save to database for authenticated users
const moderationPrefs: ModerationPreferences = {
detailed_view_collapsed: collapsed,
};
const { error } = await supabase
.from('user_preferences')
.upsert({
user_id: user.id,
moderation_preferences: moderationPrefs as unknown as Json,
updated_at: new Date().toISOString(),
}, {
onConflict: 'user_id',
});
if (error) {
handleNonCriticalError(error, {
action: 'Save moderation preferences',
userId: user.id,
});
}
} else {
// Save to localStorage for guests
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed));
} catch (error) {
logger.warn('Error saving detailed view state to localStorage', { error });
}
}
} catch (error) {
logger.warn('Error saving detailed view preferences', { error });
}
};
const toggle = () => {
const newValue = !isCollapsed;
setIsCollapsed(newValue);
savePreferences(newValue);
};
const setCollapsed = (value: boolean) => {
setIsCollapsed(value);
savePreferences(value);
};
return {
isCollapsed,
toggle,
setCollapsed,
loading,
};
}

View File

@@ -1872,6 +1872,13 @@ export type Database = {
item_id?: string
}
Relationships: [
{
foreignKeyName: "item_edit_history_item_id_fkey"
columns: ["item_id"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "item_edit_history_item_id_fkey"
columns: ["item_id"]
@@ -5682,6 +5689,13 @@ export type Database = {
submission_item_id?: string
}
Relationships: [
{
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
columns: ["submission_item_id"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
columns: ["submission_item_id"]
@@ -5694,6 +5708,7 @@ export type Database = {
submission_items: {
Row: {
action_type: string | null
approved_at: string | null
approved_entity_id: string | null
company_submission_id: string | null
created_at: string
@@ -5714,6 +5729,7 @@ export type Database = {
}
Insert: {
action_type?: string | null
approved_at?: string | null
approved_entity_id?: string | null
company_submission_id?: string | null
created_at?: string
@@ -5734,6 +5750,7 @@ export type Database = {
}
Update: {
action_type?: string | null
approved_at?: string | null
approved_entity_id?: string | null
company_submission_id?: string | null
created_at?: string
@@ -5760,6 +5777,13 @@ export type Database = {
referencedRelation: "company_submissions"
referencedColumns: ["id"]
},
{
foreignKeyName: "submission_items_depends_on_fkey"
columns: ["depends_on"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "submission_items_depends_on_fkey"
columns: ["depends_on"]
@@ -5931,6 +5955,13 @@ export type Database = {
test_session_id?: string | null
}
Relationships: [
{
foreignKeyName: "test_data_registry_submission_item_id_fkey"
columns: ["submission_item_id"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "test_data_registry_submission_item_id_fkey"
columns: ["submission_item_id"]
@@ -6306,6 +6337,76 @@ export type Database = {
}
Relationships: []
}
approval_history_detailed: {
Row: {
action_type: string | null
approval_time_seconds: number | null
approved_at: string | null
approved_entity_id: string | null
approver_avatar_url: string | null
approver_display_name: string | null
approver_id: string | null
approver_username: string | null
created_at: string | null
entity_name: string | null
entity_slug: string | null
item_id: string | null
item_type: string | null
status: string | null
submission_id: string | null
submission_type: string | null
submitted_at: string | null
submitter_avatar_url: string | null
submitter_display_name: string | null
submitter_id: string | null
submitter_username: string | null
updated_at: string | null
}
Relationships: [
{
foreignKeyName: "content_submissions_reviewer_id_fkey"
columns: ["approver_id"]
isOneToOne: false
referencedRelation: "filtered_profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "content_submissions_reviewer_id_fkey"
columns: ["approver_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "content_submissions_user_id_fkey"
columns: ["submitter_id"]
isOneToOne: false
referencedRelation: "filtered_profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "content_submissions_user_id_fkey"
columns: ["submitter_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "submission_items_submission_id_fkey"
columns: ["submission_id"]
isOneToOne: false
referencedRelation: "content_submissions"
referencedColumns: ["id"]
},
{
foreignKeyName: "submission_items_submission_id_fkey"
columns: ["submission_id"]
isOneToOne: false
referencedRelation: "moderation_queue_with_entities"
referencedColumns: ["id"]
},
]
}
data_retention_stats: {
Row: {
last_30_days: number | null
@@ -6628,17 +6729,19 @@ export type Database = {
}
}
Functions: {
analyze_data_completeness: {
Args: {
p_entity_type?: string
p_limit?: number
p_max_score?: number
p_min_score?: number
p_missing_category?: string
p_offset?: number
}
Returns: Json
}
analyze_data_completeness:
| {
Args: {
p_entity_type?: string
p_limit?: number
p_max_score?: number
p_min_score?: number
p_missing_category?: string
p_offset?: number
}
Returns: Json
}
| { Args: never; Returns: Json }
anonymize_user_submissions: {
Args: { target_user_id: string }
Returns: undefined
@@ -6816,6 +6919,7 @@ export type Database = {
Returns: string
}
extract_cf_image_id: { Args: { url: string }; Returns: string }
filter_jsonb_array_nulls: { Args: { arr: Json }; Returns: Json }
generate_deletion_confirmation_code: { Args: never; Returns: string }
generate_incident_number: { Args: never; Returns: string }
generate_notification_idempotency_key: {
@@ -6828,6 +6932,40 @@ export type Database = {
Returns: string
}
generate_ticket_number: { Args: never; Returns: string }
get_approval_history: {
Args: {
p_approver_id?: string
p_from_date?: string
p_item_type?: string
p_limit?: number
p_offset?: number
p_to_date?: string
}
Returns: {
action_type: string
approval_time_seconds: number
approved_at: string
approved_entity_id: string
approver_avatar_url: string
approver_display_name: string
approver_id: string
approver_username: string
created_at: string
entity_name: string
entity_slug: string
item_id: string
item_type: string
status: string
submission_id: string
submission_type: string
submitted_at: string
submitter_avatar_url: string
submitter_display_name: string
submitter_id: string
submitter_username: string
updated_at: string
}[]
}
get_auth0_sub_from_jwt: { Args: never; Returns: string }
get_contributor_leaderboard: {
Args: { limit_count?: number; time_period?: string }
@@ -6853,6 +6991,16 @@ export type Database = {
Args: { _profile_user_id: string; _viewer_id?: string }
Returns: Json
}
get_maintenance_tables: {
Args: never
Returns: {
indexes_size: string
row_count: number
table_name: string
table_size: string
total_size: string
}[]
}
get_my_sessions: {
Args: never
Returns: {
@@ -7053,13 +7201,13 @@ export type Database = {
monitor_slow_approvals: { Args: never; Returns: undefined }
process_approval_transaction: {
Args: {
p_approval_mode?: string
p_idempotency_key?: string
p_item_ids: string[]
p_moderator_id: string
p_parent_span_id?: string
p_request_id?: string
p_submission_id: string
p_submitter_id: string
p_trace_id?: string
}
Returns: Json
}
@@ -7086,6 +7234,7 @@ export type Database = {
}
Returns: Json
}
refresh_approval_history: { Args: never; Returns: undefined }
release_expired_locks: { Args: never; Returns: number }
release_submission_lock: {
Args: { moderator_id: string; submission_id: string }
@@ -7115,6 +7264,7 @@ export type Database = {
Returns: string
}
run_all_cleanup_jobs: { Args: never; Returns: Json }
run_analyze_table: { Args: { table_name: string }; Returns: Json }
run_data_retention_cleanup: { Args: never; Returns: Json }
run_pipeline_monitoring: {
Args: never
@@ -7124,6 +7274,7 @@ export type Database = {
status: string
}[]
}
run_reindex_table: { Args: { table_name: string }; Returns: Json }
run_system_maintenance: {
Args: never
Returns: {
@@ -7132,6 +7283,7 @@ export type Database = {
task: string
}[]
}
run_vacuum_table: { Args: { table_name: string }; Returns: Json }
set_config_value: {
Args: {
is_local?: boolean

View File

@@ -26,6 +26,7 @@ export interface QueryConfig {
currentPage: number;
pageSize: number;
sortConfig?: SortConfig;
approvalDateRange?: { from: Date | null; to: Date | null };
}
/**
@@ -53,7 +54,7 @@ export function buildSubmissionQuery(
config: QueryConfig,
skipModeratorFilter = false
) {
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config;
const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser, approvalDateRange } = config;
// Use optimized view with pre-joined profiles and entity data
let query = supabase
@@ -103,6 +104,20 @@ export function buildSubmissionQuery(
}
// 'all' and 'reviews' filters don't add any conditions
// Apply approval date range filter (only works on archive tab with approved items)
if (approvalDateRange && tab === 'archive') {
if (approvalDateRange.from) {
// Filter by checking if submission has at least one item approved on/after this date
query = query.gte('first_item_approved_at', approvalDateRange.from.toISOString());
}
if (approvalDateRange.to) {
// Add one day and use < to include the entire "to" day
const nextDay = new Date(approvalDateRange.to);
nextDay.setDate(nextDay.getDate() + 1);
query = query.lt('last_item_approved_at', nextDay.toISOString());
}
}
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
// Admins see all submissions
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions

View File

@@ -103,6 +103,12 @@ export const queryKeys = {
admin: {
databaseStats: () => ['admin', 'database-stats'] as const,
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const,
maintenanceTables: () => ['admin', 'maintenance-tables'] as const,
},
// Companies queries
companies: {
detail: (slug: string) => ['companies', 'detail', slug] as const,
},
// Analytics queries

View File

@@ -368,7 +368,7 @@ export async function fetchSystemActivities(
}
// Fetch submission reviews (approved/rejected submissions)
// Note: Content is now in submission_metadata table, but entity_name is cached in view
// Note: Content is now in submission_metadata table - need to join and filter properly
const { data: submissions, error: submissionsError } = await supabase
.from('content_submissions')
.select(`
@@ -377,8 +377,9 @@ export async function fetchSystemActivities(
status,
reviewer_id,
reviewed_at,
submission_metadata(name)
submission_metadata!inner(metadata_value)
`)
.eq('submission_metadata.metadata_key', 'name')
.not('reviewed_at', 'is', null)
.in('status', ['approved', 'rejected', 'partially_approved'])
.order('reviewed_at', { ascending: false })
@@ -415,10 +416,10 @@ export async function fetchSystemActivities(
);
for (const submission of submissions) {
// Get name from submission_metadata
// Get name from submission_metadata - extract metadata_value from the joined result
const metadata = submission.submission_metadata as any;
const entityName = Array.isArray(metadata) && metadata.length > 0
? metadata[0]?.name
? metadata[0]?.metadata_value
: undefined;
const submissionItem = itemsMap.get(submission.id);

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { Button } from '@/components/ui/button';
@@ -149,12 +151,7 @@ export default function DesignerDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
<CompanyDetailSkeleton />
</div>
);
}
@@ -181,13 +178,17 @@ export default function DesignerDetail() {
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/designers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Designers
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Designers', href: '/designers' },
{ label: designer.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this designer")}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
import { Header } from '@/components/layout/Header';
import { trackPageView } from '@/lib/viewTracking';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
@@ -159,12 +161,7 @@ export default function ManufacturerDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
<CompanyDetailSkeleton />
</div>
);
}
@@ -191,14 +188,17 @@ export default function ManufacturerDetail() {
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
<span className="md:hidden">Back</span>
<span className="hidden md:inline">Back to Manufacturers</span>
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Manufacturers', href: '/manufacturers' },
{ label: manufacturer.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this manufacturer")}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
import { Header } from '@/components/layout/Header';
import { trackPageView } from '@/lib/viewTracking';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
@@ -188,12 +190,7 @@ export default function OperatorDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
<CompanyDetailSkeleton />
</div>
);
}
@@ -220,13 +217,17 @@ export default function OperatorDetail() {
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/operators')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Operators
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Operators', href: '/operators' },
{ label: operator.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this operator")}

View File

@@ -1,5 +1,9 @@
import { useState, lazy, Suspense, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { ParkDetailSkeleton } from '@/components/loading/ParkDetailSkeleton';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { trackPageView } from '@/lib/viewTracking';
@@ -161,13 +165,7 @@ export default function ParkDetail() {
if (loading) {
return <div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
</div>
<ParkDetailSkeleton />
</div>;
}
if (!park) {
@@ -191,13 +189,17 @@ export default function ParkDetail() {
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/parks')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Parks
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Parks', href: '/parks' },
{ label: park.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditParkModalOpen(true), "Sign in to edit this park")}
@@ -435,9 +437,19 @@ export default function ParkDetail() {
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Operator</div>
<div className="text-sm text-muted-foreground">
{park.operator.name}
</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/operators/${park.operator.slug}`}
className="text-sm text-primary hover:underline"
>
{park.operator.name}
</Link>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-auto">
<CompanyPreviewCard slug={park.operator.slug} />
</HoverCardContent>
</HoverCard>
</div>
</div>}

View File

@@ -291,8 +291,9 @@ export default function Profile() {
submission_type,
status,
created_at,
submission_metadata(name)
submission_metadata!inner(metadata_value)
`)
.eq('submission_metadata.metadata_key', 'name')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10);
@@ -310,10 +311,10 @@ export default function Profile() {
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
const enriched: any = { ...sub };
// Get name from submission_metadata
// Get name from submission_metadata - extract metadata_value from the joined result
const metadata = sub.submission_metadata as any;
enriched.name = Array.isArray(metadata) && metadata.length > 0
? metadata[0]?.name
? metadata[0]?.metadata_value
: undefined;
// For photo submissions, get photo count and preview

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
import { trackPageView } from '@/lib/viewTracking';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { Button } from '@/components/ui/button';
@@ -188,12 +190,7 @@ export default function PropertyOwnerDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
</div>
</div>
<CompanyDetailSkeleton />
</div>
);
}
@@ -220,13 +217,17 @@ export default function PropertyOwnerDetail() {
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate('/owners')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Property Owners
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Property Owners', href: '/owners' },
{ label: owner.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this property owner")}

View File

@@ -1,5 +1,10 @@
import { useState, lazy, Suspense, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { RideDetailSkeleton } from '@/components/loading/RideDetailSkeleton';
import { Header } from '@/components/layout/Header';
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
import { trackPageView } from '@/lib/viewTracking';
@@ -160,13 +165,7 @@ export default function RideDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-64 bg-muted rounded-lg"></div>
<div className="h-8 bg-muted rounded w-1/2"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
</div>
<RideDetailSkeleton />
</div>
);
}
@@ -194,18 +193,27 @@ export default function RideDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Back Button and Edit Button */}
<div className="flex items-center justify-between mb-6">
<Button
variant="ghost"
onClick={() => navigate(`/parks/${ride.park?.slug}`)}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {ride.park?.name}
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Parks', href: '/parks' },
{
label: ride.park.name,
href: `/parks/${ride.park.slug}`,
showPreview: true,
previewType: 'park',
previewSlug: ride.park.slug
},
{ label: 'Rides', href: `/parks/${ride.park.slug}#rides` },
{ label: ride.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride")}
@@ -255,10 +263,20 @@ export default function RideDetail() {
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
{ride.name}
</h1>
<div className="flex items-center text-white/90 text-lg">
<MapPin className="w-5 h-5 mr-2" />
{ride.park.name}
</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/parks/${ride.park.slug}`}
className="flex items-center text-white/90 text-lg hover:text-white transition-colors"
>
<MapPin className="w-5 h-5 mr-2" />
<span className="hover:underline">{ride.park.name}</span>
</Link>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="w-auto">
<ParkPreviewCard slug={ride.park.slug} />
</HoverCardContent>
</HoverCard>
<div className="mt-3">
<VersionIndicator
entityType="ride"
@@ -471,9 +489,19 @@ export default function RideDetail() {
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Manufacturer</div>
<div className="text-sm text-muted-foreground">
{ride.manufacturer.name}
</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/manufacturers/${ride.manufacturer.slug}`}
className="text-sm text-primary hover:underline"
>
{ride.manufacturer.name}
</Link>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-auto">
<CompanyPreviewCard slug={ride.manufacturer.slug} />
</HoverCardContent>
</HoverCard>
</div>
</div>
)}
@@ -483,9 +511,19 @@ export default function RideDetail() {
<Users className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium">Designer</div>
<div className="text-sm text-muted-foreground">
{ride.designer.name}
</div>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<Link
to={`/designers/${ride.designer.slug}`}
className="text-sm text-primary hover:underline"
>
{ride.designer.name}
</Link>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-auto">
<CompanyPreviewCard slug={ride.designer.slug} />
</HoverCardContent>
</HoverCard>
</div>
</div>
)}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
@@ -167,17 +169,7 @@ export default function RideModelDetail() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="h-64 bg-muted rounded"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-48 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
<CompanyDetailSkeleton />
</div>
);
}
@@ -204,12 +196,25 @@ export default function RideModelDetail() {
<Header />
<main className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {manufacturer.name} Models
</Button>
{/* Breadcrumb Navigation */}
<EntityBreadcrumb
segments={[
{ label: 'Manufacturers', href: '/manufacturers' },
{
label: manufacturer.name,
href: `/manufacturers/${manufacturerSlug}`,
showPreview: true,
previewType: 'company',
previewSlug: manufacturerSlug || ''
},
{ label: 'Models', href: `/manufacturers/${manufacturerSlug}/models` },
{ label: model.name }
]}
className="mb-4"
/>
{/* Edit Button */}
<div className="flex justify-end mb-6">
<Button
variant="outline"
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride model")}

View File

@@ -0,0 +1,136 @@
/**
* Approval History Page
*
* Full-page view for compliance reporting with advanced filters,
* date range selection, and export functionality.
*/
import { useState } from 'react';
import { ItemApprovalHistory } from '@/components/moderation/ItemApprovalHistory';
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { X, FileCheck } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { Navigate } from 'react-router-dom';
import type { EntityType } from '@/types/submissions';
export default function ApprovalHistory() {
const { isModerator, loading: rolesLoading } = useUserRole();
const [fromDate, setFromDate] = useState<Date | null>(null);
const [toDate, setToDate] = useState<Date | null>(null);
const [itemType, setItemType] = useState<EntityType | 'all'>('all');
const [limit, setLimit] = useState<number>(100);
// Access control: moderators only
if (rolesLoading) {
return (
<div className="container mx-auto p-6">
<div className="text-center">Loading...</div>
</div>
);
}
if (!isModerator()) {
return <Navigate to="/" replace />;
}
const hasFilters = fromDate || toDate || itemType !== 'all' || limit !== 100;
const clearFilters = () => {
setFromDate(null);
setToDate(null);
setItemType('all');
setLimit(100);
};
return (
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<FileCheck className="w-8 h-8 text-primary" />
<h1 className="text-3xl font-bold">Approval History</h1>
</div>
<p className="text-muted-foreground">
Complete audit trail of all approved items with exact timestamps for compliance reporting
</p>
</div>
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Date Range Filter */}
<div className="lg:col-span-2">
<FilterDateRangePicker
label="Approval Date Range"
fromDate={fromDate}
toDate={toDate}
onFromChange={(date) => setFromDate(date || null)}
onToChange={(date) => setToDate(date || null)}
fromPlaceholder="Start Date"
toPlaceholder="End Date"
/>
</div>
{/* Item Type Filter */}
<div className="space-y-2">
<Label htmlFor="item-type">Item Type</Label>
<Select value={itemType} onValueChange={(val) => setItemType(val as EntityType | 'all')}>
<SelectTrigger id="item-type">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="park">Parks</SelectItem>
<SelectItem value="ride">Rides</SelectItem>
<SelectItem value="manufacturer">Manufacturers</SelectItem>
<SelectItem value="designer">Designers</SelectItem>
<SelectItem value="operator">Operators</SelectItem>
<SelectItem value="ride_model">Ride Models</SelectItem>
</SelectContent>
</Select>
</div>
{/* Results Limit */}
<div className="space-y-2">
<Label htmlFor="limit">Results Limit</Label>
<Select value={limit.toString()} onValueChange={(val) => setLimit(parseInt(val))}>
<SelectTrigger id="limit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Clear Filters */}
{hasFilters && (
<div className="mt-4 flex justify-end">
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="w-4 h-4 mr-2" />
Clear Filters
</Button>
</div>
)}
</CardContent>
</Card>
{/* History Table */}
<ItemApprovalHistory
dateRange={fromDate && toDate ? { from: fromDate, to: toDate } : undefined}
itemType={itemType === 'all' ? undefined : itemType}
limit={limit}
embedded={false}
/>
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useState } from 'react';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { AdminPageLayout } from '@/components/admin';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
useMaintenanceTables,
useVacuumTable,
useAnalyzeTable,
useReindexTable,
} from '@/hooks/admin/useDatabaseMaintenance';
import { Database, RefreshCw, Zap, Settings, AlertTriangle } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
export default function DatabaseMaintenance() {
const { data: tables, isLoading, refetch } = useMaintenanceTables();
const vacuumMutation = useVacuumTable();
const analyzeMutation = useAnalyzeTable();
const reindexMutation = useReindexTable();
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const handleVacuum = (tableName: string) => {
setSelectedTable(tableName);
vacuumMutation.mutate(tableName);
};
const handleAnalyze = (tableName: string) => {
setSelectedTable(tableName);
analyzeMutation.mutate(tableName);
};
const handleReindex = (tableName: string) => {
setSelectedTable(tableName);
reindexMutation.mutate(tableName);
};
const isOperationPending = (tableName: string) => {
return (
selectedTable === tableName &&
(vacuumMutation.isPending || analyzeMutation.isPending || reindexMutation.isPending)
);
};
return (
<AdminLayout>
<AdminPageLayout
title="Database Maintenance"
description="Run vacuum, analyze, and reindex operations on database tables"
>
<Alert variant="default" className="mb-6">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Superuser Access Required</AlertTitle>
<AlertDescription>
These operations require superuser privileges. They can help improve database
performance by reclaiming storage, updating statistics, and rebuilding indexes.
</AlertDescription>
</Alert>
<div className="grid gap-6 mb-6 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">VACUUM</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription className="text-xs">
Reclaims storage occupied by dead tuples and makes space available for reuse
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">ANALYZE</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription className="text-xs">
Updates statistics used by the query planner for optimal query execution plans
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">REINDEX</CardTitle>
<Settings className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription className="text-xs">
Rebuilds indexes to eliminate bloat and restore optimal index performance
</CardDescription>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Database Tables</CardTitle>
<CardDescription>
Select maintenance operations to perform on each table
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : tables && tables.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Table Name</TableHead>
<TableHead className="text-right">Rows</TableHead>
<TableHead className="text-right">Table Size</TableHead>
<TableHead className="text-right">Indexes Size</TableHead>
<TableHead className="text-right">Total Size</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tables.map((table) => (
<TableRow key={table.table_name}>
<TableCell className="font-medium">
<code className="text-sm">{table.table_name}</code>
</TableCell>
<TableCell className="text-right">
{table.row_count?.toLocaleString() || 'N/A'}
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">{table.table_size}</Badge>
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">{table.indexes_size}</Badge>
</TableCell>
<TableCell className="text-right">
<Badge variant="outline">{table.total_size}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleVacuum(table.table_name)}
disabled={isOperationPending(table.table_name)}
>
{isOperationPending(table.table_name) &&
vacuumMutation.isPending ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
'Vacuum'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleAnalyze(table.table_name)}
disabled={isOperationPending(table.table_name)}
>
{isOperationPending(table.table_name) &&
analyzeMutation.isPending ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
'Analyze'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleReindex(table.table_name)}
disabled={isOperationPending(table.table_name)}
>
{isOperationPending(table.table_name) &&
reindexMutation.isPending ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
'Reindex'
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
No tables available
</div>
)}
</CardContent>
</Card>
</AdminPageLayout>
</AdminLayout>
);
}

View File

@@ -313,6 +313,14 @@ export interface SortConfig {
direction: SortDirection;
}
/**
* Approval date range filter for moderation queue
*/
export interface ApprovalDateRangeFilter {
from: Date | null;
to: Date | null;
}
/**
* Loading states for the moderation queue
*/

View File

@@ -43,6 +43,7 @@ export interface SubmissionItemData {
rejection_reason: string | null;
created_at: string;
updated_at: string;
approved_at: string | null;
}
export interface EntityPhotoGalleryProps {

View File

@@ -266,7 +266,15 @@ export function wrapEdgeFunction(
logSpanToDatabase(span, requestId);
// Clone response to add tracking headers
const responseBody = await response.text();
// Defensive check: ensure handler returned a Response object
let responseBody: string;
if (response instanceof Response) {
responseBody = await response.text();
} else {
// Handler returned non-Response (shouldn't happen, but handle it)
addSpanEvent(span, 'warning_non_response_object');
responseBody = JSON.stringify(response);
}
const enhancedResponse = new Response(responseBody, {
status: response.status,
statusText: response.statusText,

View File

@@ -28,7 +28,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
metrics.push({
metric_name: 'api_error_count',
metric_value: errorCount as number,
metric_category: 'performance',
metric_category: 'api',
timestamp,
});
}
@@ -45,7 +45,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
metrics.push({
metric_name: 'rate_limit_violations',
metric_value: violationCount as number,
metric_category: 'security',
metric_category: 'rate_limit',
timestamp,
});
}
@@ -61,7 +61,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
metrics.push({
metric_name: 'pending_submissions',
metric_value: pendingCount as number,
metric_category: 'workflow',
metric_category: 'moderation',
timestamp,
});
}
@@ -77,7 +77,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
metrics.push({
metric_name: 'active_incidents',
metric_value: incidentCount as number,
metric_category: 'monitoring',
metric_category: 'system',
timestamp,
});
}
@@ -86,14 +86,14 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
const { data: unresolvedAlerts, error: alertsError } = await supabase
.from('system_alerts')
.select('id', { count: 'exact', head: true })
.eq('resolved', false);
.is('resolved_at', null);
if (!alertsError) {
const alertCount = unresolvedAlerts || 0;
metrics.push({
metric_name: 'unresolved_alerts',
metric_value: alertCount as number,
metric_category: 'monitoring',
metric_category: 'system',
timestamp,
});
}
@@ -112,7 +112,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
metrics.push({
metric_name: 'submission_approval_rate',
metric_value: approvalRate,
metric_category: 'workflow',
metric_category: 'moderation',
timestamp,
});
}
@@ -136,7 +136,7 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
metrics.push({
metric_name: 'avg_moderation_time',
metric_value: avgTimeMinutes,
metric_category: 'workflow',
metric_category: 'moderation',
timestamp,
});
}
@@ -155,11 +155,17 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
addSpanEvent(span, 'metrics_recorded', { count: metrics.length });
}
return {
success: true,
metrics_collected: metrics.length,
metrics: metrics.map(m => ({ name: m.metric_name, value: m.metric_value })),
};
return new Response(
JSON.stringify({
success: true,
metrics_collected: metrics.length,
metrics: metrics.map(m => ({ name: m.metric_name, value: m.metric_value })),
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
};
serve(createEdgeFunction({

View File

@@ -474,11 +474,17 @@ const handler = async (req: Request, { supabase, span, requestId }: EdgeFunction
addSpanEvent(span, 'anomaly_detection_complete', { detected: anomaliesDetected.length });
return {
success: true,
anomalies_detected: anomaliesDetected.length,
anomalies: anomaliesDetected,
};
return new Response(
JSON.stringify({
success: true,
anomalies_detected: anomaliesDetected.length,
anomalies: anomaliesDetected,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
};
serve(createEdgeFunction({

View File

@@ -1,4 +1,6 @@
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
import { corsHeadersWithTracing } from '../_shared/cors.ts';
import {
addSpanEvent,
setSpanAttributes,
@@ -16,11 +18,42 @@ import { ValidationError } from '../_shared/typeValidation.ts';
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
const { supabase, user, span: rootSpan, requestId } = context;
// Early logging - confirms request reached handler
addSpanEvent(rootSpan, 'handler_entry', {
requestId,
userId: user.id,
timestamp: Date.now()
});
setSpanAttributes(rootSpan, {
'user.id': user.id,
'function.name': 'process-selective-approval'
});
// Health check endpoint
if (req.url.includes('/health')) {
addSpanEvent(rootSpan, 'health_check_start');
const { data, error } = await supabase
.from('content_submissions')
.select('count')
.limit(1);
addSpanEvent(rootSpan, 'health_check_complete', {
dbConnected: !error,
error: error?.message
});
return new Response(JSON.stringify({
status: 'ok',
dbConnected: !error,
timestamp: Date.now(),
error: error?.message
}), {
headers: { 'Content-Type': 'application/json' },
status: error ? 500 : 200
});
}
// STEP 1: Parse and validate request
addSpanEvent(rootSpan, 'validation_start');
@@ -57,14 +90,37 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
});
addSpanEvent(rootSpan, 'validation_complete');
// STEP 2: Idempotency check
// STEP 2: Idempotency check with timeout
addSpanEvent(rootSpan, 'idempotency_check_start');
const { data: existingKey } = await supabase
const idempotencyCheckPromise = supabase
.from('submission_idempotency_keys')
.select('*')
.eq('idempotency_key', idempotencyKey)
.single();
// Add 5 second timeout for idempotency check
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Idempotency check timed out after 5s')), 5000)
);
let existingKey;
try {
const result = await Promise.race([
idempotencyCheckPromise,
timeoutPromise
]) as any;
existingKey = result.data;
} catch (timeoutError: any) {
addSpanEvent(rootSpan, 'idempotency_check_timeout', { error: timeoutError.message });
throw new Error(`Database query timeout: ${timeoutError.message}`);
}
addSpanEvent(rootSpan, 'idempotency_check_complete', {
foundKey: !!existingKey,
status: existingKey?.status
});
if (existingKey?.status === 'completed') {
addSpanEvent(rootSpan, 'idempotency_cache_hit');
setSpanAttributes(rootSpan, { 'cache.hit': true });
@@ -151,8 +207,8 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
p_moderator_id: user.id,
p_submitter_id: submission.user_id,
p_request_id: requestId,
p_trace_id: rootSpan.traceId,
p_parent_span_id: rpcSpan.spanId
p_approval_mode: 'selective',
p_idempotency_key: idempotencyKey
}
);
@@ -235,13 +291,11 @@ const handler = async (req: Request, context: { supabase: any; user: any; span:
};
// Create edge function with automatic error handling, CORS, auth, and logging
createEdgeFunction(
serve(createEdgeFunction(
{
name: 'process-selective-approval',
requireAuth: true,
corsEnabled: true,
enableTracing: true,
rateLimitTier: 'moderate'
corsHeaders: corsHeadersWithTracing,
},
handler
);
));

View File

@@ -0,0 +1,188 @@
-- Database Maintenance Functions
-- These functions allow authorized users to perform database maintenance operations
-- Function to run VACUUM on a specific table
CREATE OR REPLACE FUNCTION run_vacuum_table(table_name text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
result jsonb;
start_time timestamp;
end_time timestamp;
BEGIN
-- Only allow superusers to run this
IF NOT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'superuser'
) THEN
RAISE EXCEPTION 'Only superusers can perform database maintenance';
END IF;
start_time := clock_timestamp();
-- Execute VACUUM on the specified table
EXECUTE format('VACUUM ANALYZE %I', table_name);
end_time := clock_timestamp();
result := jsonb_build_object(
'table_name', table_name,
'operation', 'VACUUM ANALYZE',
'started_at', start_time,
'completed_at', end_time,
'duration_ms', EXTRACT(MILLISECONDS FROM (end_time - start_time)),
'success', true
);
RETURN result;
EXCEPTION
WHEN OTHERS THEN
RETURN jsonb_build_object(
'table_name', table_name,
'operation', 'VACUUM ANALYZE',
'success', false,
'error', SQLERRM
);
END;
$$;
-- Function to run ANALYZE on a specific table
CREATE OR REPLACE FUNCTION run_analyze_table(table_name text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
result jsonb;
start_time timestamp;
end_time timestamp;
BEGIN
-- Only allow superusers to run this
IF NOT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'superuser'
) THEN
RAISE EXCEPTION 'Only superusers can perform database maintenance';
END IF;
start_time := clock_timestamp();
-- Execute ANALYZE on the specified table
EXECUTE format('ANALYZE %I', table_name);
end_time := clock_timestamp();
result := jsonb_build_object(
'table_name', table_name,
'operation', 'ANALYZE',
'started_at', start_time,
'completed_at', end_time,
'duration_ms', EXTRACT(MILLISECONDS FROM (end_time - start_time)),
'success', true
);
RETURN result;
EXCEPTION
WHEN OTHERS THEN
RETURN jsonb_build_object(
'table_name', table_name,
'operation', 'ANALYZE',
'success', false,
'error', SQLERRM
);
END;
$$;
-- Function to run REINDEX on a specific table
CREATE OR REPLACE FUNCTION run_reindex_table(table_name text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
result jsonb;
start_time timestamp;
end_time timestamp;
BEGIN
-- Only allow superusers to run this
IF NOT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'superuser'
) THEN
RAISE EXCEPTION 'Only superusers can perform database maintenance';
END IF;
start_time := clock_timestamp();
-- Execute REINDEX on the specified table
EXECUTE format('REINDEX TABLE %I', table_name);
end_time := clock_timestamp();
result := jsonb_build_object(
'table_name', table_name,
'operation', 'REINDEX',
'started_at', start_time,
'completed_at', end_time,
'duration_ms', EXTRACT(MILLISECONDS FROM (end_time - start_time)),
'success', true
);
RETURN result;
EXCEPTION
WHEN OTHERS THEN
RETURN jsonb_build_object(
'table_name', table_name,
'operation', 'REINDEX',
'success', false,
'error', SQLERRM
);
END;
$$;
-- Function to get list of tables that can be maintained
CREATE OR REPLACE FUNCTION get_maintenance_tables()
RETURNS TABLE (
table_name text,
row_count bigint,
table_size text,
indexes_size text,
total_size text
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Only allow superusers to view this
IF NOT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'superuser'
) THEN
RAISE EXCEPTION 'Only superusers can view database maintenance information';
END IF;
RETURN QUERY
SELECT
t.tablename::text,
(xpath('/row/cnt/text()', xml_count))[1]::text::bigint as row_count,
pg_size_pretty(pg_total_relation_size(quote_ident(t.tablename)::regclass) - pg_indexes_size(quote_ident(t.tablename)::regclass)) as table_size,
pg_size_pretty(pg_indexes_size(quote_ident(t.tablename)::regclass)) as indexes_size,
pg_size_pretty(pg_total_relation_size(quote_ident(t.tablename)::regclass)) as total_size
FROM pg_tables t
LEFT JOIN LATERAL (
SELECT table_to_xml(t.tablename::regclass, false, true, '') as xml_count
) x ON true
WHERE t.schemaname = 'public'
ORDER BY pg_total_relation_size(quote_ident(t.tablename)::regclass) DESC;
END;
$$;

View File

@@ -0,0 +1,412 @@
-- Fix JSONB array filtering in analyze_data_completeness function
-- Replace invalid '- null::jsonb' operations with proper array filtering
-- Helper function to filter null values from JSONB arrays
CREATE OR REPLACE FUNCTION filter_jsonb_array_nulls(arr JSONB)
RETURNS JSONB
LANGUAGE SQL
IMMUTABLE
AS $$
SELECT COALESCE(
jsonb_agg(element),
'[]'::jsonb
)
FROM jsonb_array_elements_text(arr) element
WHERE element != 'null'
$$;
-- Replace analyze_data_completeness with fixed JSONB array handling
CREATE OR REPLACE FUNCTION analyze_data_completeness(
p_entity_type TEXT DEFAULT NULL,
p_min_score NUMERIC DEFAULT NULL,
p_max_score NUMERIC DEFAULT NULL,
p_missing_category TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100,
p_offset INTEGER DEFAULT 0
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_result JSONB;
v_parks JSONB;
v_rides JSONB;
v_companies JSONB;
v_ride_models JSONB;
v_locations JSONB;
v_timeline_events JSONB;
v_summary JSONB;
BEGIN
-- Parks Analysis (including historical)
WITH park_analysis AS (
SELECT
p.id,
p.name,
p.slug,
'park' as entity_type,
p.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 35 points
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 20 points
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
-- Supplementary fields (3 points each) = 9 points
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
-- Nice-to-have fields (1 point each) = 6 points
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
CASE WHEN p.status IS NULL THEN 'status' END,
CASE WHEN p.location_id IS NULL THEN 'location_id' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
CASE WHEN p.phone IS NULL THEN 'phone' END
)),
'supplementary', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.email IS NULL THEN 'email' END,
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
))
) as missing_fields
FROM parks p
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_parks
FROM park_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Rides Analysis
WITH ride_analysis AS (
SELECT
r.id,
r.name,
r.slug,
'ride' as entity_type,
r.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 42 points
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 15 points
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
-- Category-specific technical data (5 points each) = up to 10 points
(CASE
WHEN r.category = 'Roller Coaster' THEN
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
WHEN r.category = 'Water Ride' THEN
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
WHEN r.category = 'Dark Ride' THEN
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
ELSE 0
END) +
-- Supplementary fields (3 points each) = 9 points
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
CASE WHEN r.category IS NULL THEN 'category' END,
CASE WHEN r.status IS NULL THEN 'status' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
))
) as missing_fields
FROM rides r
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_rides
FROM ride_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Companies Analysis
WITH company_analysis AS (
SELECT
c.id,
c.name,
c.slug,
'company' as entity_type,
c.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 20 points
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 28 points
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 20 points
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
-- Supplementary fields (3 points each) = 6 points
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
CASE WHEN c.person_type IS NULL THEN 'person_type' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
))
) as missing_fields
FROM companies c
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_companies
FROM company_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Ride Models Analysis
WITH model_analysis AS (
SELECT
rm.id,
rm.name,
rm.slug,
'ride_model' as entity_type,
rm.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 21 points
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 10 points
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END) +
(CASE WHEN rm.introduction_year IS NOT NULL THEN 5 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN rm.category IS NULL THEN 'category' END,
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.introduction_year IS NULL THEN 'introduction_year' END
))
) as missing_fields
FROM ride_models rm
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_ride_models
FROM model_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Generate Summary
v_summary := jsonb_build_object(
'total_entities', (
SELECT COUNT(*)::INTEGER FROM (
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
UNION ALL
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
UNION ALL
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
UNION ALL
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
) all_entities
),
'avg_completeness_score', (
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
UNION ALL
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
UNION ALL
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
UNION ALL
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
) scores
),
'entities_below_50', (
SELECT COUNT(*)::INTEGER FROM (
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
UNION ALL
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
UNION ALL
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
UNION ALL
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
) all_entities
WHERE id IN (
SELECT id FROM parks WHERE description IS NULL OR manufacturer_id IS NULL
UNION
SELECT id FROM rides WHERE description IS NULL OR manufacturer_id IS NULL
UNION
SELECT id FROM companies WHERE description IS NULL
UNION
SELECT id FROM ride_models WHERE description IS NULL
)
),
'entities_100_complete', 0,
'by_entity_type', jsonb_build_object(
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
)
);
-- Build final result
v_result := jsonb_build_object(
'summary', v_summary,
'entities', jsonb_build_object(
'parks', COALESCE(v_parks, '[]'::jsonb),
'rides', COALESCE(v_rides, '[]'::jsonb),
'companies', COALESCE(v_companies, '[]'::jsonb),
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
),
'generated_at', now()
);
RETURN v_result;
END;
$$;

View File

@@ -0,0 +1,14 @@
-- Fix search_path security issue for filter_jsonb_array_nulls function
CREATE OR REPLACE FUNCTION filter_jsonb_array_nulls(arr JSONB)
RETURNS JSONB
LANGUAGE SQL
IMMUTABLE
SET search_path = public
AS $$
SELECT COALESCE(
jsonb_agg(element),
'[]'::jsonb
)
FROM jsonb_array_elements_text(arr) element
WHERE element != 'null'
$$;

View File

@@ -0,0 +1,398 @@
-- Fix analyze_data_completeness: Remove non-existent introduction_year column reference
-- The ride_models table doesn't have an introduction_year column
CREATE OR REPLACE FUNCTION analyze_data_completeness(
p_entity_type TEXT DEFAULT NULL,
p_min_score NUMERIC DEFAULT NULL,
p_max_score NUMERIC DEFAULT NULL,
p_missing_category TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100,
p_offset INTEGER DEFAULT 0
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_result JSONB;
v_parks JSONB;
v_rides JSONB;
v_companies JSONB;
v_ride_models JSONB;
v_locations JSONB;
v_timeline_events JSONB;
v_summary JSONB;
BEGIN
-- Parks Analysis (including historical)
WITH park_analysis AS (
SELECT
p.id,
p.name,
p.slug,
'park' as entity_type,
p.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 35 points
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 20 points
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
-- Supplementary fields (3 points each) = 9 points
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
-- Nice-to-have fields (1 point each) = 6 points
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
CASE WHEN p.status IS NULL THEN 'status' END,
CASE WHEN p.location_id IS NULL THEN 'location_id' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
CASE WHEN p.phone IS NULL THEN 'phone' END
)),
'supplementary', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.email IS NULL THEN 'email' END,
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
))
) as missing_fields
FROM parks p
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_parks
FROM park_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Rides Analysis
WITH ride_analysis AS (
SELECT
r.id,
r.name,
r.slug,
'ride' as entity_type,
r.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 42 points
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 15 points
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
-- Category-specific technical data (5 points each) = up to 10 points
(CASE
WHEN r.category = 'Roller Coaster' THEN
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
WHEN r.category = 'Water Ride' THEN
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
WHEN r.category = 'Dark Ride' THEN
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
ELSE 0
END) +
-- Supplementary fields (3 points each) = 9 points
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
CASE WHEN r.category IS NULL THEN 'category' END,
CASE WHEN r.status IS NULL THEN 'status' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
))
) as missing_fields
FROM rides r
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_rides
FROM ride_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Companies Analysis
WITH company_analysis AS (
SELECT
c.id,
c.name,
c.slug,
'company' as entity_type,
c.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 20 points
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 28 points
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 20 points
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
-- Supplementary fields (3 points each) = 6 points
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
CASE WHEN c.person_type IS NULL THEN 'person_type' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
))
) as missing_fields
FROM companies c
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_companies
FROM company_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Ride Models Analysis - FIXED: Removed introduction_year references (lines 306, 322)
-- Total points reduced from 70 to 65 (removed 5 points from introduction_year)
WITH model_analysis AS (
SELECT
rm.id,
rm.name,
rm.slug,
'ride_model' as entity_type,
rm.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 21 points
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 5 points
-- REMOVED: introduction_year check (was 5 points)
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN rm.category IS NULL THEN 'category' END,
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
-- REMOVED: introduction_year from missing fields tracking
))
) as missing_fields
FROM ride_models rm
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_ride_models
FROM model_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Generate Summary
v_summary := jsonb_build_object(
'total_entities', (
SELECT COUNT(*)::INTEGER FROM (
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
UNION ALL
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
UNION ALL
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
UNION ALL
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
) all_entities
),
'avg_completeness_score', (
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
UNION ALL
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
UNION ALL
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
UNION ALL
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
) scores
),
'entities_below_50', (
SELECT COUNT(*)::INTEGER FROM (
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
UNION ALL
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
UNION ALL
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
UNION ALL
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
) all_entities
WHERE id IN (
SELECT id FROM parks WHERE description IS NULL OR manufacturer_id IS NULL
UNION
SELECT id FROM rides WHERE description IS NULL OR manufacturer_id IS NULL
UNION
SELECT id FROM companies WHERE description IS NULL
UNION
SELECT id FROM ride_models WHERE description IS NULL
)
),
'entities_100_complete', 0,
'by_entity_type', jsonb_build_object(
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
)
);
-- Build final result
v_result := jsonb_build_object(
'summary', v_summary,
'entities', jsonb_build_object(
'parks', COALESCE(v_parks, '[]'::jsonb),
'rides', COALESCE(v_rides, '[]'::jsonb),
'companies', COALESCE(v_companies, '[]'::jsonb),
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
),
'generated_at', now()
);
RETURN v_result;
END;
$$;

View File

@@ -0,0 +1,129 @@
-- Fix get_recent_additions: Remove created_by joins for tables without created_by column
-- Only entity_timeline_events has created_by column, not parks/rides/companies/ride_models/locations
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 timestamp with time zone, created_by_id uuid, created_by_username text, created_by_avatar text)
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
BEGIN
RETURN QUERY
SELECT * FROM (
-- Parks - FIXED: Removed created_by join (parks table doesn't have created_by column)
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM parks p
UNION ALL
-- Rides - FIXED: Removed created_by join (rides table doesn't have created_by column)
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM rides r
LEFT JOIN parks pk ON pk.id = r.park_id
UNION ALL
-- Companies - FIXED: Removed created_by join (companies table doesn't have created_by column)
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM companies c
UNION ALL
-- Ride Models - FIXED: Removed created_by join (ride_models table doesn't have created_by column)
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM ride_models rm
UNION ALL
-- Locations - FIXED: Removed created_by join (locations table doesn't have created_by column)
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 - KEPT: This table has created_by column
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 - KEPT: This table has submitted_by column
SELECT
p.id as entity_id,
'photo'::text as entity_type,
COALESCE(p.title, 'Photo') as entity_name,
NULL::text as entity_slug,
NULL::text as park_slug,
p.cloudflare_image_url as image_url,
p.created_at as created_at,
p.submitted_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM photos p
LEFT JOIN profiles prof ON prof.user_id = p.submitted_by
) combined
ORDER BY created_at DESC
LIMIT limit_count;
END;
$function$;

View File

@@ -0,0 +1,307 @@
-- Fix analyze_data_completeness function to check appropriate fields per entity type
-- Parks don't have manufacturer_id, so we check relevant fields like description, park_type, status, location_id
-- Rides and ride_models DO have manufacturer_id, so we check that
-- Companies don't have manufacturer_id, so we check description and company_type
CREATE OR REPLACE FUNCTION analyze_data_completeness()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result jsonb;
park_data jsonb;
ride_data jsonb;
company_data jsonb;
ride_model_data jsonb;
total_entities integer;
avg_score numeric;
below_50_count integer;
complete_count integer;
BEGIN
-- Analyze Parks
WITH park_completeness AS (
SELECT
id,
name,
slug,
'park'::text as entity_type,
updated_at,
CASE
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 20
END +
CASE WHEN park_type IS NULL THEN 0 ELSE 15 END +
CASE WHEN status IS NULL THEN 0 ELSE 10 END +
CASE WHEN location_id IS NULL THEN 0 ELSE 15 END +
CASE WHEN opening_date IS NULL THEN 0 ELSE 10 END +
CASE WHEN website_url IS NULL THEN 0 ELSE 5 END +
CASE WHEN card_image_url IS NULL THEN 0 ELSE 10 END +
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 5 END +
CASE WHEN phone IS NULL THEN 0 ELSE 5 END +
CASE WHEN email IS NULL THEN 0 ELSE 5 END as completeness_score
FROM parks
),
park_missing AS (
SELECT
id,
jsonb_build_object(
'critical', ARRAY_REMOVE(ARRAY[
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
CASE WHEN park_type IS NULL THEN 'park_type' END,
CASE WHEN status IS NULL THEN 'status' END
], NULL),
'important', ARRAY_REMOVE(ARRAY[
CASE WHEN location_id IS NULL THEN 'location_id' END,
CASE WHEN opening_date IS NULL THEN 'opening_date' END
], NULL),
'valuable', ARRAY_REMOVE(ARRAY[
CASE WHEN website_url IS NULL THEN 'website_url' END,
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END
], NULL),
'supplementary', ARRAY_REMOVE(ARRAY[
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END,
CASE WHEN phone IS NULL THEN 'phone' END,
CASE WHEN email IS NULL THEN 'email' END
], NULL)
) as missing_fields
FROM parks
)
SELECT jsonb_agg(
jsonb_build_object(
'id', pc.id,
'name', pc.name,
'slug', pc.slug,
'entity_type', pc.entity_type,
'updated_at', pc.updated_at,
'completeness_score', pc.completeness_score,
'missing_fields', pm.missing_fields
)
)
INTO park_data
FROM park_completeness pc
JOIN park_missing pm ON pc.id = pm.id;
-- Analyze Rides
WITH ride_completeness AS (
SELECT
id,
name,
slug,
'ride'::text as entity_type,
updated_at,
CASE
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 20
END +
CASE WHEN manufacturer_id IS NULL THEN 0 ELSE 15 END +
CASE WHEN category IS NULL THEN 0 ELSE 15 END +
CASE WHEN status IS NULL THEN 0 ELSE 10 END +
CASE WHEN opening_date IS NULL THEN 0 ELSE 10 END +
CASE WHEN ride_model_id IS NULL THEN 0 ELSE 10 END +
CASE WHEN card_image_url IS NULL THEN 0 ELSE 10 END +
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 5 END +
CASE WHEN height_restriction IS NULL THEN 0 ELSE 3 END +
CASE WHEN max_speed IS NULL THEN 0 ELSE 2 END as completeness_score
FROM rides
),
ride_missing AS (
SELECT
id,
jsonb_build_object(
'critical', ARRAY_REMOVE(ARRAY[
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
CASE WHEN manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN category IS NULL THEN 'category' END,
CASE WHEN status IS NULL THEN 'status' END
], NULL),
'important', ARRAY_REMOVE(ARRAY[
CASE WHEN opening_date IS NULL THEN 'opening_date' END,
CASE WHEN ride_model_id IS NULL THEN 'ride_model_id' END
], NULL),
'valuable', ARRAY_REMOVE(ARRAY[
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END,
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END
], NULL),
'supplementary', ARRAY_REMOVE(ARRAY[
CASE WHEN height_restriction IS NULL THEN 'height_restriction' END,
CASE WHEN max_speed IS NULL THEN 'max_speed' END
], NULL)
) as missing_fields
FROM rides
)
SELECT jsonb_agg(
jsonb_build_object(
'id', rc.id,
'name', rc.name,
'slug', rc.slug,
'entity_type', rc.entity_type,
'updated_at', rc.updated_at,
'completeness_score', rc.completeness_score,
'missing_fields', rm.missing_fields
)
)
INTO ride_data
FROM ride_completeness rc
JOIN ride_missing rm ON rc.id = rm.id;
-- Analyze Companies
WITH company_completeness AS (
SELECT
id,
name,
slug,
'company'::text as entity_type,
updated_at,
CASE
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 25
END +
CASE WHEN company_type IS NULL THEN 0 ELSE 20 END +
CASE WHEN headquarters_location IS NULL THEN 0 ELSE 15 END +
CASE WHEN founded_year IS NULL THEN 0 ELSE 10 END +
CASE WHEN website_url IS NULL THEN 0 ELSE 10 END +
CASE WHEN logo_url IS NULL THEN 0 ELSE 10 END +
CASE WHEN card_image_url IS NULL THEN 0 ELSE 5 END +
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 5 END as completeness_score
FROM companies
),
company_missing AS (
SELECT
id,
jsonb_build_object(
'critical', ARRAY_REMOVE(ARRAY[
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
CASE WHEN company_type IS NULL THEN 'company_type' END
], NULL),
'important', ARRAY_REMOVE(ARRAY[
CASE WHEN headquarters_location IS NULL THEN 'headquarters_location' END,
CASE WHEN founded_year IS NULL THEN 'founded_year' END
], NULL),
'valuable', ARRAY_REMOVE(ARRAY[
CASE WHEN website_url IS NULL THEN 'website_url' END,
CASE WHEN logo_url IS NULL THEN 'logo_url' END
], NULL),
'supplementary', ARRAY_REMOVE(ARRAY[
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END,
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END
], NULL)
) as missing_fields
FROM companies
)
SELECT jsonb_agg(
jsonb_build_object(
'id', cc.id,
'name', cc.name,
'slug', cc.slug,
'entity_type', cc.entity_type,
'updated_at', cc.updated_at,
'completeness_score', cc.completeness_score,
'missing_fields', cm.missing_fields
)
)
INTO company_data
FROM company_completeness cc
JOIN company_missing cm ON cc.id = cm.id;
-- Analyze Ride Models
WITH ride_model_completeness AS (
SELECT
id,
name,
slug,
'ride_model'::text as entity_type,
updated_at,
CASE
WHEN description IS NULL OR length(description) <= 50 THEN 0 ELSE 25
END +
CASE WHEN manufacturer_id IS NULL THEN 0 ELSE 20 END +
CASE WHEN category IS NULL THEN 0 ELSE 20 END +
CASE WHEN card_image_url IS NULL THEN 0 ELSE 15 END +
CASE WHEN banner_image_url IS NULL THEN 0 ELSE 10 END +
CASE WHEN max_speed IS NULL THEN 0 ELSE 5 END +
CASE WHEN height IS NULL THEN 0 ELSE 5 END as completeness_score
FROM ride_models
),
ride_model_missing AS (
SELECT
id,
jsonb_build_object(
'critical', ARRAY_REMOVE(ARRAY[
CASE WHEN description IS NULL OR length(description) <= 50 THEN 'description' END,
CASE WHEN manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN category IS NULL THEN 'category' END
], NULL),
'important', ARRAY_REMOVE(ARRAY[
CASE WHEN card_image_url IS NULL THEN 'card_image_url' END
], NULL),
'valuable', ARRAY_REMOVE(ARRAY[
CASE WHEN banner_image_url IS NULL THEN 'banner_image_url' END
], NULL),
'supplementary', ARRAY_REMOVE(ARRAY[
CASE WHEN max_speed IS NULL THEN 'max_speed' END,
CASE WHEN height IS NULL THEN 'height' END
], NULL)
) as missing_fields
FROM ride_models
)
SELECT jsonb_agg(
jsonb_build_object(
'id', rmc.id,
'name', rmc.name,
'slug', rmc.slug,
'entity_type', rmc.entity_type,
'updated_at', rmc.updated_at,
'completeness_score', rmc.completeness_score,
'missing_fields', rmm.missing_fields
)
)
INTO ride_model_data
FROM ride_model_completeness rmc
JOIN ride_model_missing rmm ON rmc.id = rmm.id;
-- Calculate summary statistics
WITH all_scores AS (
SELECT (value->>'completeness_score')::numeric as score
FROM jsonb_array_elements(COALESCE(park_data, '[]'::jsonb))
UNION ALL
SELECT (value->>'completeness_score')::numeric
FROM jsonb_array_elements(COALESCE(ride_data, '[]'::jsonb))
UNION ALL
SELECT (value->>'completeness_score')::numeric
FROM jsonb_array_elements(COALESCE(company_data, '[]'::jsonb))
UNION ALL
SELECT (value->>'completeness_score')::numeric
FROM jsonb_array_elements(COALESCE(ride_model_data, '[]'::jsonb))
)
SELECT
COUNT(*)::integer,
ROUND(AVG(score), 2),
COUNT(CASE WHEN score < 50 THEN 1 END)::integer,
COUNT(CASE WHEN score = 100 THEN 1 END)::integer
INTO total_entities, avg_score, below_50_count, complete_count
FROM all_scores;
-- Build final result
result := jsonb_build_object(
'summary', jsonb_build_object(
'total_entities', COALESCE(total_entities, 0),
'avg_completeness_score', COALESCE(avg_score, 0),
'entities_below_50', COALESCE(below_50_count, 0),
'entities_100_complete', COALESCE(complete_count, 0),
'by_entity_type', jsonb_build_object(
'parks', COALESCE(jsonb_array_length(park_data), 0),
'rides', COALESCE(jsonb_array_length(ride_data), 0),
'companies', COALESCE(jsonb_array_length(company_data), 0),
'ride_models', COALESCE(jsonb_array_length(ride_model_data), 0)
)
),
'entities', jsonb_build_object(
'parks', COALESCE(park_data, '[]'::jsonb),
'rides', COALESCE(ride_data, '[]'::jsonb),
'companies', COALESCE(company_data, '[]'::jsonb),
'ride_models', COALESCE(ride_model_data, '[]'::jsonb)
),
'generated_at', to_jsonb(now())
);
RETURN result;
END;
$$;

View File

@@ -0,0 +1,4 @@
-- Fix security linter warning: Set search_path for analyze_data_completeness function
-- This prevents potential security issues from search_path manipulation
ALTER FUNCTION analyze_data_completeness() SET search_path = public;

View File

@@ -0,0 +1,527 @@
-- Fix analyze_data_completeness and get_recent_additions functions
-- Issue 1: analyze_data_completeness checks manufacturer_id on parks table (line 366) - parks don't have this field
-- Issue 2: get_recent_additions references event_title column (line 98) - correct column name is 'title'
-- Fix analyze_data_completeness: Update entities_below_50 calculation to check appropriate fields per entity type
CREATE OR REPLACE FUNCTION analyze_data_completeness(
p_entity_type TEXT DEFAULT NULL,
p_min_score NUMERIC DEFAULT NULL,
p_max_score NUMERIC DEFAULT NULL,
p_missing_category TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100,
p_offset INTEGER DEFAULT 0
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_result JSONB;
v_parks JSONB;
v_rides JSONB;
v_companies JSONB;
v_ride_models JSONB;
v_locations JSONB;
v_timeline_events JSONB;
v_summary JSONB;
BEGIN
-- Parks Analysis (including historical)
WITH park_analysis AS (
SELECT
p.id,
p.name,
p.slug,
'park' as entity_type,
p.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN p.park_type IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN p.status IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN p.location_id IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 35 points
(CASE WHEN p.description IS NOT NULL AND length(p.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN p.operator_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN p.property_owner_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 20 points
(CASE WHEN p.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.website_url IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN p.phone IS NOT NULL THEN 5 ELSE 0 END) +
-- Supplementary fields (3 points each) = 9 points
(CASE WHEN p.email IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN p.closing_date IS NOT NULL AND p.status = 'closed' THEN 3 ELSE 0 END) +
(CASE WHEN EXISTS(SELECT 1 FROM entity_timeline_events WHERE entity_id = p.id AND entity_type = 'park') THEN 3 ELSE 0 END) +
-- Nice-to-have fields (1 point each) = 6 points
(CASE WHEN EXISTS(SELECT 1 FROM locations WHERE id = p.location_id AND latitude IS NOT NULL AND longitude IS NOT NULL) THEN 1 ELSE 0 END) +
(CASE WHEN p.closing_date_precision IS NOT NULL AND p.status = 'closed' THEN 1 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.park_type IS NULL THEN 'park_type' END,
CASE WHEN p.status IS NULL THEN 'status' END,
CASE WHEN p.location_id IS NULL THEN 'location_id' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.description IS NULL OR length(p.description) <= 50 THEN 'description' END,
CASE WHEN p.operator_id IS NULL THEN 'operator_id' END,
CASE WHEN p.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN p.card_image_id IS NULL THEN 'card_image' END,
CASE WHEN p.property_owner_id IS NULL THEN 'property_owner_id' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.opening_date IS NULL THEN 'opening_date' END,
CASE WHEN p.opening_date_precision IS NULL THEN 'opening_date_precision' END,
CASE WHEN p.website_url IS NULL THEN 'website_url' END,
CASE WHEN p.phone IS NULL THEN 'phone' END
)),
'supplementary', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN p.email IS NULL THEN 'email' END,
CASE WHEN p.closing_date IS NULL AND p.status = 'closed' THEN 'closing_date' END
))
) as missing_fields
FROM parks p
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_parks
FROM park_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Rides Analysis
WITH ride_analysis AS (
SELECT
r.id,
r.name,
r.slug,
'ride' as entity_type,
r.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN r.park_id IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN r.category IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN r.status IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 42 points
(CASE WHEN r.description IS NOT NULL AND length(r.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN r.manufacturer_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.ride_model_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN r.designer_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 15 points
(CASE WHEN r.opening_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.opening_date_precision IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.ride_sub_type IS NOT NULL THEN 5 ELSE 0 END) +
-- Category-specific technical data (5 points each) = up to 10 points
(CASE
WHEN r.category = 'Roller Coaster' THEN
(CASE WHEN r.coaster_type IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.max_speed_kmh IS NOT NULL THEN 5 ELSE 0 END)
WHEN r.category = 'Water Ride' THEN
(CASE WHEN r.flume_type IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.wetness_level IS NOT NULL THEN 5 ELSE 0 END)
WHEN r.category = 'Dark Ride' THEN
(CASE WHEN r.theme_name IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN r.ride_system IS NOT NULL THEN 5 ELSE 0 END)
ELSE 0
END) +
-- Supplementary fields (3 points each) = 9 points
(CASE WHEN r.max_height_meters IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN r.length_meters IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN r.capacity_per_hour IS NOT NULL THEN 3 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.park_id IS NULL THEN 'park_id' END,
CASE WHEN r.category IS NULL THEN 'category' END,
CASE WHEN r.status IS NULL THEN 'status' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.description IS NULL OR length(r.description) <= 50 THEN 'description' END,
CASE WHEN r.manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN r.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN r.card_image_id IS NULL THEN 'card_image' END,
CASE WHEN r.ride_model_id IS NULL THEN 'ride_model_id' END,
CASE WHEN r.designer_id IS NULL THEN 'designer_id' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN r.opening_date IS NULL THEN 'opening_date' END,
CASE WHEN r.opening_date_precision IS NULL THEN 'opening_date_precision' END,
CASE WHEN r.ride_sub_type IS NULL THEN 'ride_sub_type' END
))
) as missing_fields
FROM rides r
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_rides
FROM ride_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Companies Analysis
WITH company_analysis AS (
SELECT
c.id,
c.name,
c.slug,
'company' as entity_type,
c.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 20 points
(CASE WHEN c.company_type IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN c.person_type IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 28 points
(CASE WHEN c.description IS NOT NULL AND length(c.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN c.logo_url IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN c.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN c.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 20 points
(CASE WHEN c.founded_year IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.founded_date IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.website_url IS NOT NULL THEN 5 ELSE 0 END) +
(CASE WHEN c.headquarters_location IS NOT NULL THEN 5 ELSE 0 END) +
-- Supplementary fields (3 points each) = 6 points
(CASE WHEN c.founded_date_precision IS NOT NULL THEN 3 ELSE 0 END) +
(CASE WHEN c.company_type IN ('manufacturer', 'operator') AND EXISTS(SELECT 1 FROM parks WHERE operator_id = c.id OR property_owner_id = c.id LIMIT 1) THEN 3 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.company_type IS NULL THEN 'company_type' END,
CASE WHEN c.person_type IS NULL THEN 'person_type' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.description IS NULL OR length(c.description) <= 50 THEN 'description' END,
CASE WHEN c.logo_url IS NULL THEN 'logo_url' END,
CASE WHEN c.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN c.card_image_id IS NULL THEN 'card_image' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN c.founded_year IS NULL THEN 'founded_year' END,
CASE WHEN c.founded_date IS NULL THEN 'founded_date' END,
CASE WHEN c.website_url IS NULL THEN 'website_url' END,
CASE WHEN c.headquarters_location IS NULL THEN 'headquarters_location' END
))
) as missing_fields
FROM companies c
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_companies
FROM company_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Ride Models Analysis
WITH model_analysis AS (
SELECT
rm.id,
rm.name,
rm.slug,
'ride_model' as entity_type,
rm.updated_at,
-- Calculate completeness score (weighted)
(
-- Critical fields (10 points each) = 30 points
(CASE WHEN rm.manufacturer_id IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN rm.category IS NOT NULL THEN 10 ELSE 0 END) +
(CASE WHEN rm.ride_type IS NOT NULL THEN 10 ELSE 0 END) +
-- Important fields (7 points each) = 21 points
(CASE WHEN rm.description IS NOT NULL AND length(rm.description) > 50 THEN 7 ELSE 0 END) +
(CASE WHEN rm.banner_image_id IS NOT NULL THEN 7 ELSE 0 END) +
(CASE WHEN rm.card_image_id IS NOT NULL THEN 7 ELSE 0 END) +
-- Valuable fields (5 points each) = 5 points
(CASE WHEN EXISTS(SELECT 1 FROM rides WHERE ride_model_id = rm.id LIMIT 1) THEN 5 ELSE 0 END)
)::NUMERIC / 100.0 * 100 as completeness_score,
-- Missing fields tracking (using helper function)
jsonb_build_object(
'critical', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.manufacturer_id IS NULL THEN 'manufacturer_id' END,
CASE WHEN rm.category IS NULL THEN 'category' END,
CASE WHEN rm.ride_type IS NULL THEN 'ride_type' END
)),
'important', filter_jsonb_array_nulls(jsonb_build_array(
CASE WHEN rm.description IS NULL OR length(rm.description) <= 50 THEN 'description' END,
CASE WHEN rm.banner_image_id IS NULL THEN 'banner_image' END,
CASE WHEN rm.card_image_id IS NULL THEN 'card_image' END
)),
'valuable', filter_jsonb_array_nulls(jsonb_build_array())
) as missing_fields
FROM ride_models rm
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
)
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'slug', slug,
'entity_type', entity_type,
'updated_at', updated_at,
'completeness_score', completeness_score,
'missing_fields', missing_fields
) ORDER BY completeness_score ASC, name ASC
)
INTO v_ride_models
FROM model_analysis
WHERE (p_min_score IS NULL OR completeness_score >= p_min_score)
AND (p_max_score IS NULL OR completeness_score <= p_max_score)
LIMIT p_limit OFFSET p_offset;
-- Generate Summary
v_summary := jsonb_build_object(
'total_entities', (
SELECT COUNT(*)::INTEGER FROM (
SELECT id FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
UNION ALL
SELECT id FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
UNION ALL
SELECT id FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
UNION ALL
SELECT id FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
) all_entities
),
'avg_completeness_score', (
SELECT ROUND(AVG(score)::NUMERIC, 2) FROM (
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM parks WHERE park_type IS NOT NULL AND status IS NOT NULL AND location_id IS NOT NULL
UNION ALL
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM rides WHERE park_id IS NOT NULL AND category IS NOT NULL AND status IS NOT NULL
UNION ALL
SELECT ((10 + 10)::NUMERIC / 100.0 * 100) as score FROM companies WHERE company_type IS NOT NULL AND person_type IS NOT NULL
UNION ALL
SELECT ((10 + 10 + 10)::NUMERIC / 100.0 * 100) as score FROM ride_models WHERE manufacturer_id IS NOT NULL AND category IS NOT NULL AND ride_type IS NOT NULL
) scores
),
'entities_below_50', (
SELECT COUNT(*)::INTEGER FROM (
-- Parks: Check appropriate fields (description, park_type, status, location_id)
SELECT id FROM parks
WHERE (p_entity_type IS NULL OR p_entity_type = 'park')
AND (description IS NULL OR park_type IS NULL OR status IS NULL OR location_id IS NULL)
UNION
-- Rides: Check appropriate fields (description, manufacturer_id, category, status)
SELECT id FROM rides
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')
AND (description IS NULL OR manufacturer_id IS NULL OR category IS NULL OR status IS NULL)
UNION
-- Companies: Check appropriate fields (description, company_type)
SELECT id FROM companies
WHERE (p_entity_type IS NULL OR p_entity_type = 'company')
AND (description IS NULL OR company_type IS NULL)
UNION
-- Ride Models: Check appropriate fields (description, manufacturer_id, category)
SELECT id FROM ride_models
WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model')
AND (description IS NULL OR manufacturer_id IS NULL OR category IS NULL)
) entities_with_missing_fields
),
'entities_100_complete', 0,
'by_entity_type', jsonb_build_object(
'parks', (SELECT COUNT(*)::INTEGER FROM parks WHERE (p_entity_type IS NULL OR p_entity_type = 'park')),
'rides', (SELECT COUNT(*)::INTEGER FROM rides WHERE (p_entity_type IS NULL OR p_entity_type = 'ride')),
'companies', (SELECT COUNT(*)::INTEGER FROM companies WHERE (p_entity_type IS NULL OR p_entity_type = 'company')),
'ride_models', (SELECT COUNT(*)::INTEGER FROM ride_models WHERE (p_entity_type IS NULL OR p_entity_type = 'ride_model'))
)
);
-- Build final result
v_result := jsonb_build_object(
'summary', v_summary,
'entities', jsonb_build_object(
'parks', COALESCE(v_parks, '[]'::jsonb),
'rides', COALESCE(v_rides, '[]'::jsonb),
'companies', COALESCE(v_companies, '[]'::jsonb),
'ride_models', COALESCE(v_ride_models, '[]'::jsonb)
),
'generated_at', now()
);
RETURN v_result;
END;
$$;
-- Fix get_recent_additions: Change event_title to title (correct column name)
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 timestamp with time zone, created_by_id uuid, created_by_username text, created_by_avatar text)
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM parks p
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM rides r
LEFT JOIN parks pk ON pk.id = r.park_id
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM companies c
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,
NULL::uuid as created_by_id,
NULL::text as created_by_username,
NULL::text as created_by_avatar
FROM ride_models rm
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 - FIXED: Changed event_title to title (correct column name)
SELECT
te.id as entity_id,
'timeline_event'::text as entity_type,
te.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
p.id as entity_id,
'photo'::text as entity_type,
COALESCE(p.title, 'Photo') as entity_name,
NULL::text as entity_slug,
NULL::text as park_slug,
p.cloudflare_image_url as image_url,
p.created_at as created_at,
p.submitted_by as created_by_id,
prof.username as created_by_username,
prof.avatar_url as created_by_avatar
FROM photos p
LEFT JOIN profiles prof ON prof.user_id = p.submitted_by
) combined
ORDER BY created_at DESC
LIMIT limit_count;
END;
$function$;

View File

@@ -0,0 +1,47 @@
-- Phase 2: Fix backfill_park_locations to include name and display_name
CREATE OR REPLACE FUNCTION backfill_park_locations()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_parks_updated INTEGER := 0;
v_locations_created INTEGER := 0;
v_park RECORD;
v_submission RECORD;
v_location_id UUID;
v_location_name TEXT;
BEGIN
FOR v_park IN
SELECT DISTINCT p.id, p.name, p.slug
FROM parks p
WHERE p.location_id IS NULL
LOOP
SELECT psl.name, psl.display_name, psl.country, psl.state_province, psl.city, psl.street_address,
psl.postal_code, psl.latitude, psl.longitude, psl.timezone
INTO v_submission
FROM park_submissions ps
JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
WHERE ps.park_id = v_park.id AND ps.status = 'approved' AND psl.country IS NOT NULL
ORDER BY ps.created_at DESC LIMIT 1;
IF FOUND THEN
v_location_name := COALESCE(v_submission.name, v_submission.display_name,
CONCAT_WS(', ', NULLIF(v_submission.city, ''), NULLIF(v_submission.state_province, ''), NULLIF(v_submission.country, '')));
INSERT INTO locations (name, display_name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (v_location_name, COALESCE(v_submission.display_name, v_location_name), v_submission.country, v_submission.state_province,
v_submission.city, v_submission.street_address, v_submission.postal_code, v_submission.latitude, v_submission.longitude, v_submission.timezone)
RETURNING id INTO v_location_id;
UPDATE parks SET location_id = v_location_id WHERE id = v_park.id;
v_parks_updated := v_parks_updated + 1;
v_locations_created := v_locations_created + 1;
RAISE NOTICE 'Backfilled location % (name: %) for park: % (id: %)', v_location_id, v_location_name, v_park.name, v_park.id;
END IF;
END LOOP;
RETURN jsonb_build_object('success', true, 'parks_updated', v_parks_updated, 'locations_created', v_locations_created);
END;
$$;

View File

@@ -0,0 +1,47 @@
-- Fix backfill_park_locations to join via slug instead of park_id
CREATE OR REPLACE FUNCTION backfill_park_locations()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_parks_updated INTEGER := 0;
v_locations_created INTEGER := 0;
v_park RECORD;
v_submission RECORD;
v_location_id UUID;
v_location_name TEXT;
BEGIN
FOR v_park IN
SELECT DISTINCT p.id, p.name, p.slug
FROM parks p
WHERE p.location_id IS NULL
LOOP
SELECT psl.name, psl.display_name, psl.country, psl.state_province, psl.city, psl.street_address,
psl.postal_code, psl.latitude, psl.longitude, psl.timezone
INTO v_submission
FROM park_submissions ps
JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
WHERE ps.slug = v_park.slug AND ps.status = 'approved' AND (psl.country IS NOT NULL OR psl.city IS NOT NULL)
ORDER BY ps.created_at DESC LIMIT 1;
IF FOUND THEN
v_location_name := COALESCE(v_submission.name, v_submission.display_name,
CONCAT_WS(', ', NULLIF(v_submission.city, ''), NULLIF(v_submission.state_province, ''), NULLIF(v_submission.country, '')));
INSERT INTO locations (name, display_name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (v_location_name, COALESCE(v_submission.display_name, v_location_name), v_submission.country, v_submission.state_province,
v_submission.city, v_submission.street_address, v_submission.postal_code, v_submission.latitude, v_submission.longitude, v_submission.timezone)
RETURNING id INTO v_location_id;
UPDATE parks SET location_id = v_location_id WHERE id = v_park.id;
v_parks_updated := v_parks_updated + 1;
v_locations_created := v_locations_created + 1;
RAISE NOTICE 'Backfilled location % (name: %) for park: % (id: %)', v_location_id, v_location_name, v_park.name, v_park.id;
END IF;
END LOOP;
RETURN jsonb_build_object('success', true, 'parks_updated', v_parks_updated, 'locations_created', v_locations_created);
END;
$$;

View File

@@ -0,0 +1,16 @@
-- Run backfill to populate missing park locations
DO $$
DECLARE
v_result jsonb;
BEGIN
RAISE NOTICE '========================================';
RAISE NOTICE 'Running backfill_park_locations()...';
RAISE NOTICE '========================================';
SELECT backfill_park_locations() INTO v_result;
RAISE NOTICE '========================================';
RAISE NOTICE 'Backfill Complete!';
RAISE NOTICE 'Result: %', v_result;
RAISE NOTICE '========================================';
END $$;

View File

@@ -0,0 +1,65 @@
-- Fix backfill to remove display_name (column doesn't exist in locations table)
CREATE OR REPLACE FUNCTION backfill_park_locations()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_parks_updated INTEGER := 0;
v_locations_created INTEGER := 0;
v_park RECORD;
v_submission RECORD;
v_location_id UUID;
v_location_name TEXT;
BEGIN
FOR v_park IN
SELECT DISTINCT p.id, p.name, p.slug
FROM parks p
WHERE p.location_id IS NULL
LOOP
-- Find most recent submission with location data
SELECT psl.name, psl.display_name, psl.country, psl.state_province, psl.city, psl.street_address,
psl.postal_code, psl.latitude, psl.longitude, psl.timezone
INTO v_submission
FROM park_submissions ps
JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
WHERE ps.slug = v_park.slug AND (psl.country IS NOT NULL OR psl.city IS NOT NULL)
ORDER BY ps.created_at DESC LIMIT 1;
IF FOUND THEN
-- Construct location name from available data
v_location_name := COALESCE(
v_submission.display_name,
v_submission.name,
CONCAT_WS(', ', NULLIF(v_submission.city, ''), NULLIF(v_submission.state_province, ''), NULLIF(v_submission.country, ''))
);
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (v_location_name, v_submission.country, v_submission.state_province, v_submission.city,
v_submission.street_address, v_submission.postal_code, v_submission.latitude, v_submission.longitude, v_submission.timezone)
RETURNING id INTO v_location_id;
UPDATE parks SET location_id = v_location_id, updated_at = now() WHERE id = v_park.id;
v_parks_updated := v_parks_updated + 1;
v_locations_created := v_locations_created + 1;
RAISE NOTICE '✅ Backfilled location % (name: %) for park: % (id: %)', v_location_id, v_location_name, v_park.name, v_park.id;
ELSE
RAISE NOTICE '⚠️ No location data found for park: % (slug: %)', v_park.name, v_park.slug;
END IF;
END LOOP;
RETURN jsonb_build_object('success', true, 'parks_updated', v_parks_updated, 'locations_created', v_locations_created);
END;
$$;
-- Run the backfill
DO $$
DECLARE
v_result jsonb;
BEGIN
SELECT backfill_park_locations() INTO v_result;
RAISE NOTICE '========================================';
RAISE NOTICE 'Backfill Complete! Result: %', v_result;
RAISE NOTICE '========================================';
END $$;

View File

@@ -0,0 +1,435 @@
-- ============================================================================
-- COMPLETE FIX: Location Name Handling in Approval Pipeline
-- ============================================================================
--
-- PURPOSE:
-- This migration fixes the process_approval_transaction function to properly
-- handle location names when creating parks. Without this fix, locations are
-- created without the 'name' field, causing silent failures and parks end up
-- with NULL location_id values.
--
-- WHAT THIS FIXES:
-- 1. Adds park_location_name and park_location_display_name to the SELECT
-- 2. Creates locations with proper name field during CREATE actions
-- 3. Creates locations with proper name field during UPDATE actions
-- 4. Falls back to constructing name from city/state/country if not provided
--
-- TESTING:
-- After applying, test by:
-- 1. Creating a new park submission with location data
-- 2. Approving the submission
-- 3. Verifying the park has a location_id set
-- 4. Checking the locations table has a record with proper name field
--
-- DEPLOYMENT:
-- This can be run manually via Supabase SQL Editor or applied as a migration
-- ============================================================================
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION process_approval_transaction(
p_submission_id UUID,
p_item_ids UUID[],
p_moderator_id UUID,
p_submitter_id UUID,
p_request_id TEXT DEFAULT NULL,
p_trace_id TEXT DEFAULT NULL,
p_parent_span_id TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_start_time TIMESTAMPTZ;
v_result JSONB;
v_item RECORD;
v_entity_id UUID;
v_approval_results JSONB[] := ARRAY[]::JSONB[];
v_final_status TEXT;
v_all_approved BOOLEAN := TRUE;
v_some_approved BOOLEAN := FALSE;
v_items_processed INTEGER := 0;
v_span_id TEXT;
v_resolved_park_id UUID;
v_resolved_manufacturer_id UUID;
v_resolved_ride_model_id UUID;
v_resolved_operator_id UUID;
v_resolved_property_owner_id UUID;
v_resolved_location_id UUID;
v_location_name TEXT;
BEGIN
v_start_time := clock_timestamp();
v_span_id := gen_random_uuid()::text;
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
v_span_id, p_trace_id, p_parent_span_id, EXTRACT(EPOCH FROM v_start_time) * 1000, p_submission_id, array_length(p_item_ids, 1);
END IF;
RAISE NOTICE '[%] Starting atomic approval transaction for submission %', COALESCE(p_request_id, 'NO_REQUEST_ID'), p_submission_id;
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
PERFORM set_config('app.submission_id', p_submission_id::text, true);
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
IF NOT EXISTS (
SELECT 1 FROM content_submissions
WHERE id = p_submission_id AND (assigned_to = p_moderator_id OR assigned_to IS NULL) AND status IN ('pending', 'partially_approved')
) THEN
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed' USING ERRCODE = '42501';
END IF;
-- ========================================================================
-- CRITICAL FIX: Added park_location_name and park_location_display_name
-- ========================================================================
FOR v_item IN
SELECT si.*,
ps.name as park_name, ps.slug as park_slug, ps.description as park_description, ps.park_type, ps.status as park_status,
ps.location_id, ps.operator_id, ps.property_owner_id, ps.opening_date as park_opening_date, ps.closing_date as park_closing_date,
ps.opening_date_precision as park_opening_date_precision, ps.closing_date_precision as park_closing_date_precision,
ps.website_url as park_website_url, ps.phone as park_phone, ps.email as park_email,
ps.banner_image_url as park_banner_image_url, ps.banner_image_id as park_banner_image_id,
ps.card_image_url as park_card_image_url, ps.card_image_id as park_card_image_id,
psl.name as park_location_name, psl.display_name as park_location_display_name,
psl.country as park_location_country, psl.state_province as park_location_state, psl.city as park_location_city,
psl.street_address as park_location_street, psl.postal_code as park_location_postal,
psl.latitude as park_location_lat, psl.longitude as park_location_lng, psl.timezone as park_location_timezone,
rs.name as ride_name, rs.slug as ride_slug, rs.park_id as ride_park_id, rs.category as ride_category, rs.status as ride_status,
rs.manufacturer_id, rs.ride_model_id, rs.opening_date as ride_opening_date, rs.closing_date as ride_closing_date,
rs.opening_date_precision as ride_opening_date_precision, rs.closing_date_precision as ride_closing_date_precision,
rs.description as ride_description, rs.banner_image_url as ride_banner_image_url, rs.banner_image_id as ride_banner_image_id,
rs.card_image_url as ride_card_image_url, rs.card_image_id as ride_card_image_id,
cs.name as company_name, cs.slug as company_slug, cs.description as company_description, cs.company_type,
cs.website_url as company_website_url, cs.founded_year, cs.founded_date, cs.founded_date_precision,
cs.headquarters_location, cs.logo_url, cs.person_type,
cs.banner_image_url as company_banner_image_url, cs.banner_image_id as company_banner_image_id,
cs.card_image_url as company_card_image_url, cs.card_image_id as company_card_image_id,
rms.name as ride_model_name, rms.slug as ride_model_slug, rms.manufacturer_id as ride_model_manufacturer_id,
rms.category as ride_model_category, rms.description as ride_model_description,
rms.banner_image_url as ride_model_banner_image_url, rms.banner_image_id as ride_model_banner_image_id,
rms.card_image_url as ride_model_card_image_url, rms.card_image_id as ride_model_card_image_id,
phs.entity_id as photo_entity_id, phs.entity_type as photo_entity_type, phs.title as photo_title
FROM submission_items si
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
LEFT JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
WHERE si.id = ANY(p_item_ids)
ORDER BY si.order_index, si.created_at
LOOP
BEGIN
v_items_processed := v_items_processed + 1;
v_entity_id := NULL;
v_resolved_park_id := NULL; v_resolved_manufacturer_id := NULL; v_resolved_ride_model_id := NULL;
v_resolved_operator_id := NULL; v_resolved_property_owner_id := NULL; v_resolved_location_id := NULL;
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
p_trace_id, v_span_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_item.id, v_item.item_type, v_item.action_type;
END IF;
IF v_item.action_type = 'create' THEN
IF v_item.item_type = 'park' THEN
-- ========================================================================
-- CRITICAL FIX: Create location with name field
-- ========================================================================
IF v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL THEN
-- Construct a name for the location, prioritizing display_name, then name, then city/state/country
v_location_name := COALESCE(
v_item.park_location_display_name,
v_item.park_location_name,
CONCAT_WS(', ',
NULLIF(v_item.park_location_city, ''),
NULLIF(v_item.park_location_state, ''),
NULLIF(v_item.park_location_country, '')
)
);
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (
v_location_name,
v_item.park_location_country,
v_item.park_location_state,
v_item.park_location_city,
v_item.park_location_street,
v_item.park_location_postal,
v_item.park_location_lat,
v_item.park_location_lng,
v_item.park_location_timezone
)
RETURNING id INTO v_resolved_location_id;
RAISE NOTICE '[%] Created location % (name: %) for park submission',
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
END IF;
-- Resolve temporary references
IF v_item.operator_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_operator_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('operator', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
IF v_item.property_owner_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_property_owner_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('property_owner', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
INSERT INTO parks (name, slug, description, park_type, status, location_id, operator_id, property_owner_id,
opening_date, closing_date, opening_date_precision, closing_date_precision, website_url, phone, email,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.park_name, v_item.park_slug, v_item.park_description, v_item.park_type, v_item.park_status,
COALESCE(v_resolved_location_id, v_item.location_id),
COALESCE(v_item.operator_id, v_resolved_operator_id),
COALESCE(v_item.property_owner_id, v_resolved_property_owner_id),
v_item.park_opening_date, v_item.park_closing_date,
v_item.park_opening_date_precision, v_item.park_closing_date_precision,
v_item.park_website_url, v_item.park_phone, v_item.park_email,
v_item.park_banner_image_url, v_item.park_banner_image_id,
v_item.park_card_image_url, v_item.park_card_image_id
)
RETURNING id INTO v_entity_id;
ELSIF v_item.item_type = 'ride' THEN
IF v_item.ride_park_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_park_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type = 'park' AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
IF v_item.manufacturer_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
IF v_item.ride_model_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_ride_model_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type = 'ride_model' AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
INSERT INTO rides (name, slug, park_id, category, status, manufacturer_id, ride_model_id,
opening_date, closing_date, opening_date_precision, closing_date_precision, description,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.ride_name, v_item.ride_slug, COALESCE(v_item.ride_park_id, v_resolved_park_id),
v_item.ride_category, v_item.ride_status,
COALESCE(v_item.manufacturer_id, v_resolved_manufacturer_id),
COALESCE(v_item.ride_model_id, v_resolved_ride_model_id),
v_item.ride_opening_date, v_item.ride_closing_date,
v_item.ride_opening_date_precision, v_item.ride_closing_date_precision,
v_item.ride_description, v_item.ride_banner_image_url, v_item.ride_banner_image_id,
v_item.ride_card_image_url, v_item.ride_card_image_id
)
RETURNING id INTO v_entity_id;
IF v_entity_id IS NOT NULL AND v_item.ride_submission_id IS NOT NULL THEN
INSERT INTO ride_technical_specifications (ride_id, specification_key, specification_value, unit, display_order)
SELECT v_entity_id, specification_key, specification_value, unit, display_order
FROM ride_technical_specifications WHERE ride_id = v_item.ride_submission_id;
INSERT INTO ride_coaster_stats (ride_id, stat_key, stat_value, unit, display_order)
SELECT v_entity_id, stat_key, stat_value, unit, display_order
FROM ride_coaster_stats WHERE ride_id = v_item.ride_submission_id;
END IF;
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
INSERT INTO companies (name, slug, description, company_type, person_type, website_url, founded_year,
founded_date, founded_date_precision, headquarters_location, logo_url,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.company_name, v_item.company_slug, v_item.company_description, v_item.company_type,
v_item.person_type, v_item.company_website_url, v_item.founded_year,
v_item.founded_date, v_item.founded_date_precision, v_item.headquarters_location, v_item.logo_url,
v_item.company_banner_image_url, v_item.company_banner_image_id,
v_item.company_card_image_url, v_item.company_card_image_id
)
RETURNING id INTO v_entity_id;
ELSIF v_item.item_type = 'ride_model' THEN
IF v_item.ride_model_manufacturer_id IS NULL THEN
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
END IF;
INSERT INTO ride_models (name, slug, manufacturer_id, category, description,
banner_image_url, banner_image_id, card_image_url, card_image_id)
VALUES (
v_item.ride_model_name, v_item.ride_model_slug,
COALESCE(v_item.ride_model_manufacturer_id, v_resolved_manufacturer_id),
v_item.ride_model_category, v_item.ride_model_description,
v_item.ride_model_banner_image_url, v_item.ride_model_banner_image_id,
v_item.ride_model_card_image_url, v_item.ride_model_card_image_id
)
RETURNING id INTO v_entity_id;
ELSIF v_item.item_type = 'photo' THEN
INSERT INTO entity_photos (entity_id, entity_type, title, photo_submission_id)
VALUES (v_item.photo_entity_id, v_item.photo_entity_type, v_item.photo_title, v_item.photo_submission_id)
RETURNING id INTO v_entity_id;
ELSE
RAISE EXCEPTION 'Unknown item type for create: %', v_item.item_type;
END IF;
ELSIF v_item.action_type = 'update' THEN
IF v_item.entity_id IS NULL THEN
RAISE EXCEPTION 'Update action requires entity_id';
END IF;
IF v_item.item_type = 'park' THEN
-- ========================================================================
-- CRITICAL FIX: Create location with name field for updates too
-- ========================================================================
IF v_item.location_id IS NULL AND (v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL) THEN
v_location_name := COALESCE(
v_item.park_location_display_name,
v_item.park_location_name,
CONCAT_WS(', ',
NULLIF(v_item.park_location_city, ''),
NULLIF(v_item.park_location_state, ''),
NULLIF(v_item.park_location_country, '')
)
);
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
VALUES (
v_location_name,
v_item.park_location_country,
v_item.park_location_state,
v_item.park_location_city,
v_item.park_location_street,
v_item.park_location_postal,
v_item.park_location_lat,
v_item.park_location_lng,
v_item.park_location_timezone
)
RETURNING id INTO v_resolved_location_id;
RAISE NOTICE '[%] Created location % (name: %) for park update',
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
END IF;
UPDATE parks SET
name = v_item.park_name, slug = v_item.park_slug, description = v_item.park_description,
park_type = v_item.park_type, status = v_item.park_status,
location_id = COALESCE(v_resolved_location_id, v_item.location_id),
operator_id = v_item.operator_id, property_owner_id = v_item.property_owner_id,
opening_date = v_item.park_opening_date, closing_date = v_item.park_closing_date,
opening_date_precision = v_item.park_opening_date_precision,
closing_date_precision = v_item.park_closing_date_precision,
website_url = v_item.park_website_url, phone = v_item.park_phone, email = v_item.park_email,
banner_image_url = v_item.park_banner_image_url, banner_image_id = v_item.park_banner_image_id,
card_image_url = v_item.park_card_image_url, card_image_id = v_item.park_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type = 'ride' THEN
UPDATE rides SET
name = v_item.ride_name, slug = v_item.ride_slug, park_id = v_item.ride_park_id,
category = v_item.ride_category, status = v_item.ride_status,
manufacturer_id = v_item.manufacturer_id, ride_model_id = v_item.ride_model_id,
opening_date = v_item.ride_opening_date, closing_date = v_item.ride_closing_date,
opening_date_precision = v_item.ride_opening_date_precision,
closing_date_precision = v_item.ride_closing_date_precision,
description = v_item.ride_description,
banner_image_url = v_item.ride_banner_image_url, banner_image_id = v_item.ride_banner_image_id,
card_image_url = v_item.ride_card_image_url, card_image_id = v_item.ride_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
UPDATE companies SET
name = v_item.company_name, slug = v_item.company_slug, description = v_item.company_description,
company_type = v_item.company_type, person_type = v_item.person_type,
website_url = v_item.company_website_url, founded_year = v_item.founded_year,
founded_date = v_item.founded_date, founded_date_precision = v_item.founded_date_precision,
headquarters_location = v_item.headquarters_location, logo_url = v_item.logo_url,
banner_image_url = v_item.company_banner_image_url, banner_image_id = v_item.company_banner_image_id,
card_image_url = v_item.company_card_image_url, card_image_id = v_item.card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type = 'ride_model' THEN
UPDATE ride_models SET
name = v_item.ride_model_name, slug = v_item.ride_model_slug,
manufacturer_id = v_item.ride_model_manufacturer_id,
category = v_item.ride_model_category, description = v_item.ride_model_description,
banner_image_url = v_item.ride_model_banner_image_url, banner_image_id = v_item.ride_model_banner_image_id,
card_image_url = v_item.ride_model_card_image_url, card_image_id = v_item.ride_model_card_image_id,
updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSIF v_item.item_type = 'photo' THEN
UPDATE entity_photos SET title = v_item.photo_title, updated_at = now()
WHERE id = v_item.entity_id;
v_entity_id := v_item.entity_id;
ELSE
RAISE EXCEPTION 'Unknown item type for update: %', v_item.item_type;
END IF;
ELSE
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
END IF;
UPDATE submission_items SET approved_entity_id = v_entity_id, approved_at = now(), status = 'approved'
WHERE id = v_item.id;
v_approval_results := array_append(v_approval_results, jsonb_build_object(
'item_id', v_item.id, 'status', 'approved', 'entity_id', v_entity_id
));
v_some_approved := TRUE;
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
v_approval_results := array_append(v_approval_results, jsonb_build_object(
'item_id', v_item.id, 'status', 'failed', 'error', SQLERRM
));
v_all_approved := FALSE;
RAISE;
END;
END LOOP;
IF v_all_approved THEN
v_final_status := 'approved';
ELSIF v_some_approved THEN
v_final_status := 'partially_approved';
ELSE
v_final_status := 'rejected';
END IF;
UPDATE content_submissions SET
status = v_final_status,
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
reviewer_id = p_moderator_id,
reviewed_at = now()
WHERE id = p_submission_id;
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
v_span_id, p_trace_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_items_processed, v_final_status;
END IF;
RETURN jsonb_build_object(
'success', v_all_approved,
'status', v_final_status,
'items_processed', v_items_processed,
'results', v_approval_results,
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
);
END;
$$;
GRANT EXECUTE ON FUNCTION process_approval_transaction TO authenticated;
COMMENT ON FUNCTION process_approval_transaction IS
'✅ FIXED 2025-11-12: Now properly creates location records with name field during park approval/update.
This prevents parks from being created with NULL location_id values due to silent INSERT failures.';

View File

@@ -0,0 +1,326 @@
-- ============================================================================
-- Fix: Remove non-existent approved_at column from submission_items update
-- ============================================================================
-- The submission_items table does not have an approved_at column.
-- This migration removes that reference from the process_approval_transaction function.
-- ============================================================================
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION process_approval_transaction(
p_submission_id UUID,
p_item_ids UUID[],
p_moderator_id UUID,
p_submitter_id UUID,
p_request_id TEXT DEFAULT NULL,
p_trace_id TEXT DEFAULT NULL,
p_parent_span_id TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_start_time TIMESTAMPTZ;
v_result JSONB;
v_item RECORD;
v_item_data JSONB;
v_resolved_refs JSONB;
v_entity_id UUID;
v_approval_results JSONB[] := ARRAY[]::JSONB[];
v_final_status TEXT;
v_all_approved BOOLEAN := TRUE;
v_some_approved BOOLEAN := FALSE;
v_items_processed INTEGER := 0;
v_span_id TEXT;
BEGIN
v_start_time := clock_timestamp();
v_span_id := gen_random_uuid()::text;
-- Log span start with trace context
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
v_span_id,
p_trace_id,
p_parent_span_id,
EXTRACT(EPOCH FROM v_start_time) * 1000,
p_submission_id,
array_length(p_item_ids, 1);
END IF;
RAISE NOTICE '[%] Starting atomic approval transaction for submission %',
COALESCE(p_request_id, 'NO_REQUEST_ID'),
p_submission_id;
-- ========================================================================
-- STEP 1: Set session variables (transaction-scoped with is_local=true)
-- ========================================================================
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
PERFORM set_config('app.submission_id', p_submission_id::text, true);
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
-- ========================================================================
-- STEP 2: Validate submission ownership and lock status
-- ========================================================================
IF NOT EXISTS (
SELECT 1 FROM content_submissions
WHERE id = p_submission_id
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
AND status IN ('pending', 'partially_approved')
) THEN
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
USING ERRCODE = '42501';
END IF;
-- ========================================================================
-- STEP 3: Process each item sequentially within this transaction
-- ========================================================================
FOR v_item IN
SELECT
si.*,
ps.name as park_name,
ps.slug as park_slug,
ps.description as park_description,
ps.park_type,
ps.status as park_status,
ps.location_id,
ps.operator_id,
ps.property_owner_id,
ps.opening_date as park_opening_date,
ps.closing_date as park_closing_date,
ps.opening_date_precision as park_opening_date_precision,
ps.closing_date_precision as park_closing_date_precision,
ps.website_url as park_website_url,
ps.phone as park_phone,
ps.email as park_email,
ps.banner_image_url as park_banner_image_url,
ps.banner_image_id as park_banner_image_id,
ps.card_image_url as park_card_image_url,
ps.card_image_id as park_card_image_id,
rs.name as ride_name,
rs.slug as ride_slug,
rs.park_id as ride_park_id,
rs.category as ride_category,
rs.status as ride_status,
rs.manufacturer_id,
rs.ride_model_id,
rs.opening_date as ride_opening_date,
rs.closing_date as ride_closing_date,
rs.opening_date_precision as ride_opening_date_precision,
rs.closing_date_precision as ride_closing_date_precision,
rs.description as ride_description,
rs.banner_image_url as ride_banner_image_url,
rs.banner_image_id as ride_banner_image_id,
rs.card_image_url as ride_card_image_url,
rs.card_image_id as ride_card_image_id,
cs.name as company_name,
cs.slug as company_slug,
cs.description as company_description,
cs.website_url as company_website_url,
cs.founded_year,
cs.banner_image_url as company_banner_image_url,
cs.banner_image_id as company_banner_image_id,
cs.card_image_url as company_card_image_url,
cs.card_image_id as company_card_image_id,
rms.name as ride_model_name,
rms.slug as ride_model_slug,
rms.manufacturer_id as ride_model_manufacturer_id,
rms.category as ride_model_category,
rms.description as ride_model_description,
rms.banner_image_url as ride_model_banner_image_url,
rms.banner_image_id as ride_model_banner_image_id,
rms.card_image_url as ride_model_card_image_url,
rms.card_image_id as ride_model_card_image_id,
phs.entity_id as photo_entity_id,
phs.entity_type as photo_entity_type,
phs.title as photo_title
FROM submission_items si
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
WHERE si.id = ANY(p_item_ids)
ORDER BY si.order_index, si.created_at
LOOP
BEGIN
v_items_processed := v_items_processed + 1;
-- Log item processing span event
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
p_trace_id,
v_span_id,
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
v_item.id,
v_item.item_type,
v_item.action_type;
END IF;
-- Build item data based on entity type
IF v_item.item_type = 'park' THEN
v_item_data := jsonb_build_object(
'name', v_item.park_name,
'slug', v_item.park_slug,
'description', v_item.park_description,
'park_type', v_item.park_type,
'status', v_item.park_status,
'location_id', v_item.location_id,
'operator_id', v_item.operator_id,
'property_owner_id', v_item.property_owner_id,
'opening_date', v_item.park_opening_date,
'closing_date', v_item.park_closing_date,
'opening_date_precision', v_item.park_opening_date_precision,
'closing_date_precision', v_item.park_closing_date_precision,
'website_url', v_item.park_website_url,
'phone', v_item.park_phone,
'email', v_item.park_email,
'banner_image_url', v_item.park_banner_image_url,
'banner_image_id', v_item.park_banner_image_id,
'card_image_url', v_item.park_card_image_url,
'card_image_id', v_item.park_card_image_id
);
ELSIF v_item.item_type = 'ride' THEN
v_item_data := jsonb_build_object(
'name', v_item.ride_name,
'slug', v_item.ride_slug,
'park_id', v_item.ride_park_id,
'category', v_item.ride_category,
'status', v_item.ride_status,
'manufacturer_id', v_item.manufacturer_id,
'ride_model_id', v_item.ride_model_id,
'opening_date', v_item.ride_opening_date,
'closing_date', v_item.ride_closing_date,
'opening_date_precision', v_item.ride_opening_date_precision,
'closing_date_precision', v_item.ride_closing_date_precision,
'description', v_item.ride_description,
'banner_image_url', v_item.ride_banner_image_url,
'banner_image_id', v_item.ride_banner_image_id,
'card_image_url', v_item.ride_card_image_url,
'card_image_id', v_item.ride_card_image_id
);
-- FIX: Support both granular company types AND consolidated 'company' type
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
v_item_data := jsonb_build_object(
'name', v_item.company_name,
'slug', v_item.company_slug,
'description', v_item.company_description,
'website_url', v_item.company_website_url,
'founded_year', v_item.founded_year,
'banner_image_url', v_item.company_banner_image_url,
'banner_image_id', v_item.company_banner_image_id,
'card_image_url', v_item.company_card_image_url,
'card_image_id', v_item.company_card_image_id
);
ELSIF v_item.item_type = 'ride_model' THEN
v_item_data := jsonb_build_object(
'name', v_item.ride_model_name,
'slug', v_item.ride_model_slug,
'manufacturer_id', v_item.ride_model_manufacturer_id,
'category', v_item.ride_model_category,
'description', v_item.ride_model_description,
'banner_image_url', v_item.ride_model_banner_image_url,
'banner_image_id', v_item.ride_model_banner_image_id,
'card_image_url', v_item.ride_model_card_image_url,
'card_image_id', v_item.ride_model_card_image_id
);
ELSIF v_item.item_type = 'photo' THEN
v_item_data := jsonb_build_object(
'entity_id', v_item.photo_entity_id,
'entity_type', v_item.photo_entity_type,
'title', v_item.photo_title
);
ELSE
RAISE EXCEPTION 'Unknown item type: %', v_item.item_type;
END IF;
-- Resolve temporary references
v_resolved_refs := resolve_temp_references(v_item_data, p_submission_id);
-- Perform the action
IF v_item.action_type = 'create' THEN
v_entity_id := perform_create(v_item.item_type, v_resolved_refs, p_submitter_id, p_submission_id);
ELSIF v_item.action_type = 'update' THEN
IF v_item.entity_id IS NULL THEN
RAISE EXCEPTION 'Update action requires entity_id';
END IF;
PERFORM perform_update(v_item.item_type, v_item.entity_id, v_resolved_refs, p_submitter_id, p_submission_id);
v_entity_id := v_item.entity_id;
ELSE
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
END IF;
-- Update submission_item with approved entity (removed approved_at - column doesn't exist)
UPDATE submission_items
SET approved_entity_id = v_entity_id,
status = 'approved',
updated_at = now()
WHERE id = v_item.id;
-- Track approval results
v_approval_results := array_append(v_approval_results, jsonb_build_object(
'item_id', v_item.id,
'status', 'approved',
'entity_id', v_entity_id
));
v_some_approved := TRUE;
EXCEPTION
WHEN OTHERS THEN
-- Log the error
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
-- Track failure
v_approval_results := array_append(v_approval_results, jsonb_build_object(
'item_id', v_item.id,
'status', 'failed',
'error', SQLERRM
));
v_all_approved := FALSE;
-- Re-raise to rollback transaction
RAISE;
END;
END LOOP;
-- ========================================================================
-- STEP 4: Update submission status
-- ========================================================================
IF v_all_approved THEN
v_final_status := 'approved';
ELSIF v_some_approved THEN
v_final_status := 'partially_approved';
ELSE
v_final_status := 'rejected';
END IF;
UPDATE content_submissions
SET status = v_final_status,
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
reviewer_id = p_moderator_id,
reviewed_at = now()
WHERE id = p_submission_id;
-- Log span end
IF p_trace_id IS NOT NULL THEN
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
v_span_id,
p_trace_id,
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
v_items_processed,
v_final_status;
END IF;
-- Return result
RETURN jsonb_build_object(
'success', v_all_approved,
'status', v_final_status,
'items_processed', v_items_processed,
'results', v_approval_results,
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
);
END;
$$;

View File

@@ -0,0 +1,271 @@
-- Add approved_at column to submission_items table
ALTER TABLE submission_items
ADD COLUMN approved_at timestamp with time zone;
-- Add index for analytics queries (filtered index for performance)
CREATE INDEX idx_submission_items_approved_at
ON submission_items(approved_at)
WHERE approved_at IS NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN submission_items.approved_at IS
'Timestamp when this specific item was approved by a moderator. NULL for pending/rejected items.';
-- Drop existing function to update parameter signature
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
-- Recreate process_approval_transaction function with approved_at support
CREATE OR REPLACE FUNCTION process_approval_transaction(
p_submission_id UUID,
p_item_ids UUID[],
p_moderator_id UUID,
p_submitter_id UUID,
p_request_id TEXT DEFAULT NULL,
p_approval_mode TEXT DEFAULT 'full',
p_idempotency_key TEXT DEFAULT NULL
) RETURNS JSONB AS $$
DECLARE
v_item RECORD;
v_entity_id UUID;
v_entity_type TEXT;
v_action_type TEXT;
v_item_data JSONB;
v_approved_items JSONB := '[]'::JSONB;
v_failed_items JSONB := '[]'::JSONB;
v_submission_type TEXT;
v_result JSONB;
v_error_message TEXT;
v_error_detail TEXT;
v_start_time TIMESTAMP := clock_timestamp();
v_duration_ms INTEGER;
v_rollback_triggered BOOLEAN := FALSE;
v_lock_acquired BOOLEAN := FALSE;
BEGIN
-- Validate moderator has permission
IF NOT is_moderator(p_moderator_id) THEN
RAISE EXCEPTION 'User % does not have moderator privileges', p_moderator_id
USING ERRCODE = 'insufficient_privilege';
END IF;
-- Get submission type
SELECT submission_type INTO v_submission_type
FROM content_submissions
WHERE id = p_submission_id;
IF v_submission_type IS NULL THEN
RAISE EXCEPTION 'Submission % not found', p_submission_id
USING ERRCODE = 'no_data_found';
END IF;
-- Acquire advisory lock
IF NOT pg_try_advisory_xact_lock(hashtext(p_submission_id::TEXT)) THEN
RAISE EXCEPTION 'Could not acquire lock for submission %', p_submission_id
USING ERRCODE = '55P03';
END IF;
v_lock_acquired := TRUE;
-- Process each item
FOR v_item IN
SELECT si.*
FROM submission_items si
WHERE si.submission_id = p_submission_id
AND si.id = ANY(p_item_ids)
AND si.status = 'pending'
ORDER BY si.order_index
LOOP
BEGIN
v_entity_type := v_item.item_type;
v_action_type := v_item.action_type;
v_item_data := v_item.item_data;
-- Create/update entity based on type and action
IF v_action_type = 'create' THEN
IF v_entity_type = 'park' THEN
INSERT INTO parks (name, slug, description, location_id, operator_id, property_owner_id)
SELECT
v_item_data->>'name',
v_item_data->>'slug',
v_item_data->>'description',
(v_item_data->>'location_id')::UUID,
(v_item_data->>'operator_id')::UUID,
(v_item_data->>'property_owner_id')::UUID
RETURNING id INTO v_entity_id;
ELSIF v_entity_type = 'ride' THEN
INSERT INTO rides (name, slug, park_id, manufacturer_id, designer_id)
SELECT
v_item_data->>'name',
v_item_data->>'slug',
(v_item_data->>'park_id')::UUID,
(v_item_data->>'manufacturer_id')::UUID,
(v_item_data->>'designer_id')::UUID
RETURNING id INTO v_entity_id;
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
INSERT INTO companies (name, slug, company_type, description)
SELECT
v_item_data->>'name',
v_item_data->>'slug',
v_entity_type,
v_item_data->>'description'
RETURNING id INTO v_entity_id;
ELSE
RAISE EXCEPTION 'Unsupported entity type: %', v_entity_type;
END IF;
ELSIF v_action_type = 'edit' THEN
v_entity_id := (v_item_data->>'entity_id')::UUID;
IF v_entity_type = 'park' THEN
UPDATE parks SET
name = COALESCE(v_item_data->>'name', name),
description = COALESCE(v_item_data->>'description', description),
location_id = COALESCE((v_item_data->>'location_id')::UUID, location_id),
updated_at = now()
WHERE id = v_entity_id;
ELSIF v_entity_type = 'ride' THEN
UPDATE rides SET
name = COALESCE(v_item_data->>'name', name),
description = COALESCE(v_item_data->>'description', description),
updated_at = now()
WHERE id = v_entity_id;
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
UPDATE companies SET
name = COALESCE(v_item_data->>'name', name),
description = COALESCE(v_item_data->>'description', description),
updated_at = now()
WHERE id = v_entity_id;
END IF;
END IF;
-- Update submission item with approved status and timestamp
UPDATE submission_items
SET
approved_entity_id = v_entity_id,
status = 'approved',
approved_at = now(),
updated_at = now()
WHERE id = v_item.id;
-- Add to success list
v_approved_items := v_approved_items || jsonb_build_object(
'item_id', v_item.id,
'entity_id', v_entity_id,
'entity_type', v_entity_type
);
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS
v_error_message = MESSAGE_TEXT,
v_error_detail = PG_EXCEPTION_DETAIL;
-- Add to failed list
v_failed_items := v_failed_items || jsonb_build_object(
'item_id', v_item.id,
'error', v_error_message,
'detail', v_error_detail
);
-- Mark item as failed
UPDATE submission_items
SET
status = 'flagged',
rejection_reason = v_error_message,
updated_at = now()
WHERE id = v_item.id;
END;
END LOOP;
-- Update submission status based on approval mode
IF p_approval_mode = 'selective' THEN
UPDATE content_submissions
SET
status = 'partially_approved',
reviewed_at = now(),
reviewer_id = p_moderator_id,
updated_at = now()
WHERE id = p_submission_id;
ELSE
UPDATE content_submissions
SET
status = 'approved',
reviewed_at = now(),
reviewer_id = p_moderator_id,
resolved_at = now(),
updated_at = now()
WHERE id = p_submission_id;
END IF;
-- Calculate duration
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
-- Log metrics
INSERT INTO approval_transaction_metrics (
submission_id,
moderator_id,
submitter_id,
items_count,
success,
duration_ms,
request_id,
rollback_triggered
) VALUES (
p_submission_id,
p_moderator_id,
p_submitter_id,
jsonb_array_length(v_approved_items),
jsonb_array_length(v_failed_items) = 0,
v_duration_ms,
p_request_id,
v_rollback_triggered
);
-- Build result
v_result := jsonb_build_object(
'success', TRUE,
'approved_items', v_approved_items,
'failed_items', v_failed_items,
'duration_ms', v_duration_ms
);
RETURN v_result;
EXCEPTION WHEN OTHERS THEN
v_rollback_triggered := TRUE;
GET STACKED DIAGNOSTICS
v_error_message = MESSAGE_TEXT,
v_error_detail = PG_EXCEPTION_DETAIL;
-- Log failed transaction
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
INSERT INTO approval_transaction_metrics (
submission_id,
moderator_id,
submitter_id,
items_count,
success,
duration_ms,
error_message,
error_details,
request_id,
rollback_triggered
) VALUES (
p_submission_id,
p_moderator_id,
p_submitter_id,
array_length(p_item_ids, 1),
FALSE,
v_duration_ms,
v_error_message,
v_error_detail,
p_request_id,
v_rollback_triggered
);
RAISE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View File

@@ -0,0 +1,259 @@
-- Fix security warning: Add search_path to process_approval_transaction
CREATE OR REPLACE FUNCTION process_approval_transaction(
p_submission_id UUID,
p_item_ids UUID[],
p_moderator_id UUID,
p_submitter_id UUID,
p_request_id TEXT DEFAULT NULL,
p_approval_mode TEXT DEFAULT 'full',
p_idempotency_key TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_item RECORD;
v_entity_id UUID;
v_entity_type TEXT;
v_action_type TEXT;
v_item_data JSONB;
v_approved_items JSONB := '[]'::JSONB;
v_failed_items JSONB := '[]'::JSONB;
v_submission_type TEXT;
v_result JSONB;
v_error_message TEXT;
v_error_detail TEXT;
v_start_time TIMESTAMP := clock_timestamp();
v_duration_ms INTEGER;
v_rollback_triggered BOOLEAN := FALSE;
v_lock_acquired BOOLEAN := FALSE;
BEGIN
-- Validate moderator has permission
IF NOT is_moderator(p_moderator_id) THEN
RAISE EXCEPTION 'User % does not have moderator privileges', p_moderator_id
USING ERRCODE = 'insufficient_privilege';
END IF;
-- Get submission type
SELECT submission_type INTO v_submission_type
FROM content_submissions
WHERE id = p_submission_id;
IF v_submission_type IS NULL THEN
RAISE EXCEPTION 'Submission % not found', p_submission_id
USING ERRCODE = 'no_data_found';
END IF;
-- Acquire advisory lock
IF NOT pg_try_advisory_xact_lock(hashtext(p_submission_id::TEXT)) THEN
RAISE EXCEPTION 'Could not acquire lock for submission %', p_submission_id
USING ERRCODE = '55P03';
END IF;
v_lock_acquired := TRUE;
-- Process each item
FOR v_item IN
SELECT si.*
FROM submission_items si
WHERE si.submission_id = p_submission_id
AND si.id = ANY(p_item_ids)
AND si.status = 'pending'
ORDER BY si.order_index
LOOP
BEGIN
v_entity_type := v_item.item_type;
v_action_type := v_item.action_type;
v_item_data := v_item.item_data;
-- Create/update entity based on type and action
IF v_action_type = 'create' THEN
IF v_entity_type = 'park' THEN
INSERT INTO parks (name, slug, description, location_id, operator_id, property_owner_id)
SELECT
v_item_data->>'name',
v_item_data->>'slug',
v_item_data->>'description',
(v_item_data->>'location_id')::UUID,
(v_item_data->>'operator_id')::UUID,
(v_item_data->>'property_owner_id')::UUID
RETURNING id INTO v_entity_id;
ELSIF v_entity_type = 'ride' THEN
INSERT INTO rides (name, slug, park_id, manufacturer_id, designer_id)
SELECT
v_item_data->>'name',
v_item_data->>'slug',
(v_item_data->>'park_id')::UUID,
(v_item_data->>'manufacturer_id')::UUID,
(v_item_data->>'designer_id')::UUID
RETURNING id INTO v_entity_id;
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
INSERT INTO companies (name, slug, company_type, description)
SELECT
v_item_data->>'name',
v_item_data->>'slug',
v_entity_type,
v_item_data->>'description'
RETURNING id INTO v_entity_id;
ELSE
RAISE EXCEPTION 'Unsupported entity type: %', v_entity_type;
END IF;
ELSIF v_action_type = 'edit' THEN
v_entity_id := (v_item_data->>'entity_id')::UUID;
IF v_entity_type = 'park' THEN
UPDATE parks SET
name = COALESCE(v_item_data->>'name', name),
description = COALESCE(v_item_data->>'description', description),
location_id = COALESCE((v_item_data->>'location_id')::UUID, location_id),
updated_at = now()
WHERE id = v_entity_id;
ELSIF v_entity_type = 'ride' THEN
UPDATE rides SET
name = COALESCE(v_item_data->>'name', name),
description = COALESCE(v_item_data->>'description', description),
updated_at = now()
WHERE id = v_entity_id;
ELSIF v_entity_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
UPDATE companies SET
name = COALESCE(v_item_data->>'name', name),
description = COALESCE(v_item_data->>'description', description),
updated_at = now()
WHERE id = v_entity_id;
END IF;
END IF;
-- Update submission item with approved status and timestamp
UPDATE submission_items
SET
approved_entity_id = v_entity_id,
status = 'approved',
approved_at = now(),
updated_at = now()
WHERE id = v_item.id;
-- Add to success list
v_approved_items := v_approved_items || jsonb_build_object(
'item_id', v_item.id,
'entity_id', v_entity_id,
'entity_type', v_entity_type
);
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS
v_error_message = MESSAGE_TEXT,
v_error_detail = PG_EXCEPTION_DETAIL;
-- Add to failed list
v_failed_items := v_failed_items || jsonb_build_object(
'item_id', v_item.id,
'error', v_error_message,
'detail', v_error_detail
);
-- Mark item as failed
UPDATE submission_items
SET
status = 'flagged',
rejection_reason = v_error_message,
updated_at = now()
WHERE id = v_item.id;
END;
END LOOP;
-- Update submission status based on approval mode
IF p_approval_mode = 'selective' THEN
UPDATE content_submissions
SET
status = 'partially_approved',
reviewed_at = now(),
reviewer_id = p_moderator_id,
updated_at = now()
WHERE id = p_submission_id;
ELSE
UPDATE content_submissions
SET
status = 'approved',
reviewed_at = now(),
reviewer_id = p_moderator_id,
resolved_at = now(),
updated_at = now()
WHERE id = p_submission_id;
END IF;
-- Calculate duration
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
-- Log metrics
INSERT INTO approval_transaction_metrics (
submission_id,
moderator_id,
submitter_id,
items_count,
success,
duration_ms,
request_id,
rollback_triggered
) VALUES (
p_submission_id,
p_moderator_id,
p_submitter_id,
jsonb_array_length(v_approved_items),
jsonb_array_length(v_failed_items) = 0,
v_duration_ms,
p_request_id,
v_rollback_triggered
);
-- Build result
v_result := jsonb_build_object(
'success', TRUE,
'approved_items', v_approved_items,
'failed_items', v_failed_items,
'duration_ms', v_duration_ms
);
RETURN v_result;
EXCEPTION WHEN OTHERS THEN
v_rollback_triggered := TRUE;
GET STACKED DIAGNOSTICS
v_error_message = MESSAGE_TEXT,
v_error_detail = PG_EXCEPTION_DETAIL;
-- Log failed transaction
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
INSERT INTO approval_transaction_metrics (
submission_id,
moderator_id,
submitter_id,
items_count,
success,
duration_ms,
error_message,
error_details,
request_id,
rollback_triggered
) VALUES (
p_submission_id,
p_moderator_id,
p_submitter_id,
array_length(p_item_ids, 1),
FALSE,
v_duration_ms,
v_error_message,
v_error_detail,
p_request_id,
v_rollback_triggered
);
RAISE;
END;
$$;

View File

@@ -0,0 +1,158 @@
-- Create materialized view for approval history with detailed audit trail
CREATE MATERIALIZED VIEW approval_history_detailed AS
SELECT
si.id as item_id,
si.submission_id,
si.item_type,
si.action_type,
si.status,
si.approved_at,
si.approved_entity_id,
si.created_at,
si.updated_at,
-- Calculate approval duration (seconds)
EXTRACT(EPOCH FROM (si.approved_at - si.created_at)) as approval_time_seconds,
-- Submission context
cs.submission_type,
cs.user_id as submitter_id,
cs.reviewer_id as approver_id,
cs.submitted_at,
-- Submitter profile
p_submitter.username as submitter_username,
p_submitter.display_name as submitter_display_name,
p_submitter.avatar_url as submitter_avatar_url,
-- Approver profile
p_approver.username as approver_username,
p_approver.display_name as approver_display_name,
p_approver.avatar_url as approver_avatar_url,
-- Entity slugs for linking (dynamic based on item_type)
CASE
WHEN si.item_type = 'park' THEN (SELECT slug FROM parks WHERE id = si.approved_entity_id)
WHEN si.item_type = 'ride' THEN (SELECT slug FROM rides WHERE id = si.approved_entity_id)
WHEN si.item_type = 'manufacturer' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'manufacturer')
WHEN si.item_type = 'designer' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'designer')
WHEN si.item_type = 'operator' THEN (SELECT slug FROM companies WHERE id = si.approved_entity_id AND company_type = 'operator')
WHEN si.item_type = 'ride_model' THEN (SELECT slug FROM ride_models WHERE id = si.approved_entity_id)
ELSE NULL
END as entity_slug,
-- Entity names for display
CASE
WHEN si.item_type = 'park' THEN (SELECT name FROM parks WHERE id = si.approved_entity_id)
WHEN si.item_type = 'ride' THEN (SELECT name FROM rides WHERE id = si.approved_entity_id)
WHEN si.item_type = 'manufacturer' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'manufacturer')
WHEN si.item_type = 'designer' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'designer')
WHEN si.item_type = 'operator' THEN (SELECT name FROM companies WHERE id = si.approved_entity_id AND company_type = 'operator')
WHEN si.item_type = 'ride_model' THEN (SELECT name FROM ride_models WHERE id = si.approved_entity_id)
ELSE NULL
END as entity_name
FROM submission_items si
JOIN content_submissions cs ON cs.id = si.submission_id
LEFT JOIN profiles p_submitter ON p_submitter.user_id = cs.user_id
LEFT JOIN profiles p_approver ON p_approver.user_id = cs.reviewer_id
WHERE si.approved_at IS NOT NULL
AND si.status = 'approved'
ORDER BY si.approved_at DESC;
-- Create indexes for fast lookups
CREATE INDEX idx_approval_history_approved_at ON approval_history_detailed(approved_at DESC);
CREATE INDEX idx_approval_history_item_type ON approval_history_detailed(item_type);
CREATE INDEX idx_approval_history_approver ON approval_history_detailed(approver_id);
CREATE INDEX idx_approval_history_submitter ON approval_history_detailed(submitter_id);
-- Function to refresh the materialized view
CREATE OR REPLACE FUNCTION refresh_approval_history()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY approval_history_detailed;
END;
$$;
-- Security-definer function to query approval history (moderators only)
CREATE OR REPLACE FUNCTION get_approval_history(
p_item_type text DEFAULT NULL,
p_approver_id uuid DEFAULT NULL,
p_from_date timestamptz DEFAULT NULL,
p_to_date timestamptz DEFAULT NULL,
p_limit integer DEFAULT 100,
p_offset integer DEFAULT 0
)
RETURNS TABLE (
item_id uuid,
submission_id uuid,
item_type text,
action_type text,
status text,
approved_at timestamptz,
approved_entity_id uuid,
created_at timestamptz,
updated_at timestamptz,
approval_time_seconds numeric,
submission_type text,
submitter_id uuid,
approver_id uuid,
submitted_at timestamptz,
submitter_username text,
submitter_display_name text,
submitter_avatar_url text,
approver_username text,
approver_display_name text,
approver_avatar_url text,
entity_slug text,
entity_name text
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
-- Check if user is a moderator
IF NOT is_moderator(auth.uid()) THEN
RAISE EXCEPTION 'Access denied: Moderator role required';
END IF;
-- Return filtered results
RETURN QUERY
SELECT
ahd.item_id,
ahd.submission_id,
ahd.item_type,
ahd.action_type,
ahd.status,
ahd.approved_at,
ahd.approved_entity_id,
ahd.created_at,
ahd.updated_at,
ahd.approval_time_seconds,
ahd.submission_type,
ahd.submitter_id,
ahd.approver_id,
ahd.submitted_at,
ahd.submitter_username,
ahd.submitter_display_name,
ahd.submitter_avatar_url,
ahd.approver_username,
ahd.approver_display_name,
ahd.approver_avatar_url,
ahd.entity_slug,
ahd.entity_name
FROM approval_history_detailed ahd
WHERE (p_item_type IS NULL OR ahd.item_type = p_item_type)
AND (p_approver_id IS NULL OR ahd.approver_id = p_approver_id)
AND (p_from_date IS NULL OR ahd.approved_at >= p_from_date)
AND (p_to_date IS NULL OR ahd.approved_at < p_to_date + interval '1 day')
ORDER BY ahd.approved_at DESC
LIMIT p_limit
OFFSET p_offset;
END;
$$;
-- Grant execute permission to authenticated users (function checks moderator role internally)
GRANT EXECUTE ON FUNCTION get_approval_history TO authenticated;
COMMENT ON MATERIALIZED VIEW approval_history_detailed IS 'Materialized view storing approval history data - access via get_approval_history() function';
COMMENT ON FUNCTION refresh_approval_history() IS 'Refreshes the approval history materialized view - call after bulk approvals';
COMMENT ON FUNCTION get_approval_history IS 'Query approval history with filters - moderators only';

View File

@@ -0,0 +1,12 @@
-- Add moderation_preferences column to user_preferences table
-- This stores moderator UI preferences like detailed view collapsed state
ALTER TABLE public.user_preferences
ADD COLUMN IF NOT EXISTS moderation_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN public.user_preferences.moderation_preferences IS
'Stores moderator UI preferences like detailed view collapsed state';
-- Add GIN index for efficient JSONB queries
CREATE INDEX IF NOT EXISTS idx_user_preferences_moderation_prefs
ON public.user_preferences USING gin(moderation_preferences);