mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-25 05:31:12 -05:00
Compare commits
33 Commits
2468d3cc18
...
edit/edt-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8bea4b798 | ||
|
|
250e7c488a | ||
|
|
46c08e10e8 | ||
|
|
b22546e7f2 | ||
|
|
7b0825e772 | ||
|
|
1a57b4f33f | ||
|
|
4c7731410f | ||
|
|
beacf481d8 | ||
|
|
00054f817d | ||
|
|
d18632c2b2 | ||
|
|
09c320f508 | ||
|
|
8422bc378f | ||
|
|
5531376edf | ||
|
|
b6d1b99f2b | ||
|
|
d24de6a9e6 | ||
|
|
c3cab84132 | ||
|
|
ab9d424240 | ||
|
|
617e079c5a | ||
|
|
3cb2c39acf | ||
|
|
3867d30aac | ||
|
|
fdfa1739e5 | ||
|
|
361231bfac | ||
|
|
2ccfe8c48a | ||
|
|
fd4e21734f | ||
|
|
9bab4358e3 | ||
|
|
5b5bd4d62e | ||
|
|
d435bda06a | ||
|
|
888ef0224a | ||
|
|
78e29f9e49 | ||
|
|
842861af8c | ||
|
|
348ab23d26 | ||
|
|
b58a0a7741 | ||
|
|
e2ee11b9f5 |
183
docs/LOCATION_FIX_SUMMARY.md
Normal file
183
docs/LOCATION_FIX_SUMMARY.md
Normal 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
|
||||
439
docs/migrations/fix_location_handling_complete.sql
Normal file
439
docs/migrations/fix_location_handling_complete.sql
Normal 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
|
||||
-- ============================================================================
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
34
src/components/layout/PageTransition.tsx
Normal file
34
src/components/layout/PageTransition.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/loading/CompanyDetailSkeleton.tsx
Normal file
98
src/components/loading/CompanyDetailSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/loading/ParkDetailSkeleton.tsx
Normal file
101
src/components/loading/ParkDetailSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/loading/RideDetailSkeleton.tsx
Normal file
106
src/components/loading/RideDetailSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
54
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
54
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DetailedViewCollapsibleProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible wrapper for detailed field-by-field view sections
|
||||
* Provides expand/collapse functionality with visual indicators
|
||||
*/
|
||||
export function DetailedViewCollapsible({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
children,
|
||||
className
|
||||
}: DetailedViewCollapsibleProps) {
|
||||
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"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
All Fields (Detailed View)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground normal-case font-normal">
|
||||
{isCollapsed ? 'Show' : 'Hide'}
|
||||
</span>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="mt-3">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
321
src/components/moderation/ItemApprovalHistory.tsx
Normal 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>;
|
||||
};
|
||||
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal 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';
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
|
||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar } 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';
|
||||
@@ -7,17 +7,21 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||
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 { 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,11 +41,14 @@ export const QueueFilters = ({
|
||||
activeEntityFilter,
|
||||
activeStatusFilter,
|
||||
sortConfig,
|
||||
activeTab,
|
||||
approvalDateRange,
|
||||
isMobile,
|
||||
isLoading = false,
|
||||
onEntityFilterChange,
|
||||
onStatusFilterChange,
|
||||
onSortChange,
|
||||
onApprovalDateRangeChange,
|
||||
onClearFilters,
|
||||
showClearButton,
|
||||
onRefresh,
|
||||
@@ -53,6 +60,7 @@ export const QueueFilters = ({
|
||||
const activeFilterCount = [
|
||||
activeEntityFilter !== 'all' ? 1 : 0,
|
||||
activeStatusFilter !== 'all' ? 1 : 0,
|
||||
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
@@ -164,6 +172,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) */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,6 +36,7 @@ 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();
|
||||
@@ -188,17 +191,14 @@ 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}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,17 +211,14 @@ 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}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -234,17 +231,14 @@ 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}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -257,17 +251,14 @@ 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}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -280,17 +271,14 @@ 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}>
|
||||
<SubmissionChangesDisplay
|
||||
item={item}
|
||||
view="detailed"
|
||||
showImages={showImages}
|
||||
submissionId={submissionId}
|
||||
/>
|
||||
</div>
|
||||
</DetailedViewCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
87
src/components/navigation/EntityBreadcrumb.tsx
Normal file
87
src/components/navigation/EntityBreadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
80
src/components/preview/CompanyPreviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/preview/ParkPreviewCard.tsx
Normal file
112
src/components/preview/ParkPreviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
src/hooks/admin/useDatabaseMaintenance.ts
Normal file
135
src/hooks/admin/useDatabaseMaintenance.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
currentPage: pagination.currentPage,
|
||||
pageSize: pagination.pageSize,
|
||||
sortConfig: filters.debouncedSortConfig,
|
||||
approvalDateRange: filters.debouncedApprovalDateRange,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
36
src/hooks/preview/useCompanyPreview.ts
Normal file
36
src/hooks/preview/useCompanyPreview.ts
Normal 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
|
||||
});
|
||||
}
|
||||
39
src/hooks/preview/useParkPreview.ts
Normal file
39
src/hooks/preview/useParkPreview.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
48
src/hooks/useDetailedViewState.ts
Normal file
48
src/hooks/useDetailedViewState.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
const STORAGE_KEY = 'detailed-view-collapsed';
|
||||
|
||||
interface UseDetailedViewStateReturn {
|
||||
isCollapsed: boolean;
|
||||
toggle: () => void;
|
||||
setCollapsed: (value: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage detailed view collapsed/expanded state
|
||||
* Syncs with localStorage for persistence across sessions
|
||||
* Defaults to collapsed to reduce visual clutter
|
||||
*/
|
||||
export function useDetailedViewState(): UseDetailedViewStateReturn {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
// Initialize from localStorage on mount
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
// Default to collapsed (true) to reduce visual clutter
|
||||
return stored ? JSON.parse(stored) : true;
|
||||
} catch (error) {
|
||||
logger.warn('Error reading detailed view state from localStorage', { error });
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Sync to localStorage when state changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
|
||||
} catch (error) {
|
||||
logger.warn('Error saving detailed view state to localStorage', { error });
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const toggle = () => setIsCollapsed(prev => !prev);
|
||||
|
||||
const setCollapsed = (value: boolean) => setIsCollapsed(value);
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggle,
|
||||
setCollapsed,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
136
src/pages/admin/ApprovalHistory.tsx
Normal file
136
src/pages/admin/ApprovalHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
src/pages/admin/DatabaseMaintenance.tsx
Normal file
224
src/pages/admin/DatabaseMaintenance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SubmissionItemData {
|
||||
rejection_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
export interface EntityPhotoGalleryProps {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
);
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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'
|
||||
$$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -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$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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.';
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user