Compare commits

...

33 Commits

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

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

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

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

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

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

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

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

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

X-Lovable-Edit-ID: edt-a95a840d-e7e7-4f9e-aa25-03bb68194aee
2025-11-12 14:36:50 +00:00
gpt-engineer-app[bot]
3d3ae57ee3 testing changes with virtual file cleanup 2025-11-12 14:36:49 +00:00
gpt-engineer-app[bot]
46c08e10e8 Add item-level approval history
Introduce ItemLevelApprovalHistory component to display which specific submission items were approved, when, and by whom, and integrate it into QueueItem between metadata and audit trail. The component shows item names, approval timestamps, action types, and reviewer info.
2025-11-12 14:28:50 +00:00
gpt-engineer-app[bot]
b22546e7f2 Add audit trail and filters
Implements audit trail view for item approvals, adds approval date range filtering to moderation queue, and wires up UI and backend components (Approval History page, ItemApprovalHistory component, materialized view-based history, and query/filters integration) to support compliant reporting and time-based moderation filtering.
2025-11-12 14:06:34 +00:00
gpt-engineer-app[bot]
7b0825e772 Add approved_at support
Added approved_at column to submission_items, updated process_approval_transaction to set approved_at on approvals, and updated TypeScript types to include approved_at. migrations and generated types updated accordingly.
2025-11-12 13:45:30 +00:00
gpt-engineer-app[bot]
1a57b4f33f Add approved_at support
- Add approved_at column to submission_items and index
- Update process_approval_transaction to set approved_at on approval
- Extend TypeScript types to include approved_at for submission items
2025-11-12 13:40:07 +00:00
gpt-engineer-app[bot]
4c7731410f Add approved_at column and update flow
Implements migration to add approved_at to submission_items, creates index, and updates process_approval_transaction to set approved_at on approvals. Also updates TypeScript types to include approved_at and aligns edge function behavior accordingly.
2025-11-12 13:36:22 +00:00
gpt-engineer-app[bot]
beacf481d8 Fix approved_at reference
Recreated process_approval_transaction RPC without updating approved_at
drops erroneous column usage and updates updated_at explicitly to mark approvals.
2025-11-12 13:31:05 +00:00
gpt-engineer-app[bot]
00054f817d Fix edge function serve wrap
Add missing serve() wrapper and correct CORS configuration in supabase/functions/process-selective-approval/index.ts, and import necessary modules. This ensures the edge function handles requests and applies proper CORS headers.
2025-11-12 13:21:16 +00:00
gpt-engineer-app[bot]
d18632c2b2 Improve moderation edge flow and timeout handling
- Add early logging and health check to process-selective-approval edge function
- Implement idempotency check with timeout to avoid edge timeouts
- Expose health endpoint for connectivity diagnostics
- Increase client moderation action timeout from 30s to 60s
- Update moderation actions hook to accommodate longer timeouts
2025-11-12 13:15:54 +00:00
gpt-engineer-app[bot]
09c320f508 Remove version number badge from VersionIndicator
Replace the badge with unchanged Last edited and View History UI, keeping dialog and history functionality intact. Update RollbackDialog to support preview of changes and wider dialog, and integrate existing diff logic to preview before rollback. Adjust VersionIndicator to stop displaying version number while preserving UI elements.
2025-11-12 05:06:45 +00:00
gpt-engineer-app[bot]
8422bc378f Remove version number badge from Indicator
Remove the version number badge display in VersionIndicator.tsx:
- drop Badge import and Clock icon import
- replace compact mode text with History
- remove version number badge in non-compact rendering
 Keeps last-edited time and history functionality intact.
2025-11-12 05:03:25 +00:00
gpt-engineer-app[bot]
5531376edf Fix span duplicates and metrics
Implements complete plan to resolve duplicate span_id issues and metric collection errors:
- Ensure edge handlers return proper Response objects to prevent double logging
- Update collect-metrics to use valid metric categories, fix system_alerts query, and adjust returns
- Apply detect-anomalies adjustments if needed and add defensive handling in wrapper
- Prepare ground for end-to-end verification of location-related fixes
2025-11-12 04:57:54 +00:00
gpt-engineer-app[bot]
b6d1b99f2b Update process_approval_transaction for locations
Applies the fix to properly handle and insert park location names during approval, ensuring location data is created/updated with correct name fields for Lagoon and future submissions.
2025-11-12 04:53:48 +00:00
gpt-engineer-app[bot]
d24de6a9e6 Fix location name handling in approval
Implement comprehensive update to process_approval_transaction to ensure location records are created with proper name fields during park creation and updates, and prepare migration to apply fixes in multiple phases. Includes backfill improvements and targeted fixes for Lagoon.
2025-11-12 04:42:21 +00:00
gpt-engineer-app[bot]
c3cab84132 Fix Lagoon location backfill
Backfilled Lagoon park with a proper location by updating backfill_park_locations to include name/display data and linking to location_id. Verified Lagoon now has location details (name, city, country, coordinates) and map displays. Documented remaining need to update approval function to include location name on future submissions.
2025-11-12 04:34:31 +00:00
gpt-engineer-app[bot]
ab9d424240 Backfill Lagoon location
Investigate Lagoon park to ensure location_id is populated after the backfill. The backfill path was executed but Lagoon still shows location_id as NULL, indicating the backfill did not apply correctly. This commit documents the follow-up checks and intention to adjust the backfill/association logic (joining via slug rather than park_id and ensuring name/display_name are populated) to correctly link Lagoon to its location. No frontend changes.
2025-11-12 04:33:19 +00:00
gpt-engineer-app[bot]
617e079c5a Backfill park locations again
Run backfill to populate lagoon location and fix submission join
- Re-run and refine backfill to ensure Lagoon and similar parks receive proper location data by correcting join via slug and handling name/display_name in location inserts.
2025-11-12 04:32:39 +00:00
gpt-engineer-app[bot]
3cb2c39acf Backfill Lagoon Location
Implement one-time script to populate missing park locations ( Lagoon and others ) by creating a backfill that assigns proper location data and updates parks with new location_id after ensuring backfill function fixes.
2025-11-12 04:28:16 +00:00
38 changed files with 3699 additions and 91 deletions

View File

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

View File

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

View File

@@ -79,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"));
@@ -387,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 />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1872,6 +1872,13 @@ export type Database = {
item_id?: string
}
Relationships: [
{
foreignKeyName: "item_edit_history_item_id_fkey"
columns: ["item_id"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "item_edit_history_item_id_fkey"
columns: ["item_id"]
@@ -5682,6 +5689,13 @@ export type Database = {
submission_item_id?: string
}
Relationships: [
{
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
columns: ["submission_item_id"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
columns: ["submission_item_id"]
@@ -5694,6 +5708,7 @@ export type Database = {
submission_items: {
Row: {
action_type: string | null
approved_at: string | null
approved_entity_id: string | null
company_submission_id: string | null
created_at: string
@@ -5714,6 +5729,7 @@ export type Database = {
}
Insert: {
action_type?: string | null
approved_at?: string | null
approved_entity_id?: string | null
company_submission_id?: string | null
created_at?: string
@@ -5734,6 +5750,7 @@ export type Database = {
}
Update: {
action_type?: string | null
approved_at?: string | null
approved_entity_id?: string | null
company_submission_id?: string | null
created_at?: string
@@ -5760,6 +5777,13 @@ export type Database = {
referencedRelation: "company_submissions"
referencedColumns: ["id"]
},
{
foreignKeyName: "submission_items_depends_on_fkey"
columns: ["depends_on"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "submission_items_depends_on_fkey"
columns: ["depends_on"]
@@ -5931,6 +5955,13 @@ export type Database = {
test_session_id?: string | null
}
Relationships: [
{
foreignKeyName: "test_data_registry_submission_item_id_fkey"
columns: ["submission_item_id"]
isOneToOne: false
referencedRelation: "approval_history_detailed"
referencedColumns: ["item_id"]
},
{
foreignKeyName: "test_data_registry_submission_item_id_fkey"
columns: ["submission_item_id"]
@@ -6306,6 +6337,76 @@ export type Database = {
}
Relationships: []
}
approval_history_detailed: {
Row: {
action_type: string | null
approval_time_seconds: number | null
approved_at: string | null
approved_entity_id: string | null
approver_avatar_url: string | null
approver_display_name: string | null
approver_id: string | null
approver_username: string | null
created_at: string | null
entity_name: string | null
entity_slug: string | null
item_id: string | null
item_type: string | null
status: string | null
submission_id: string | null
submission_type: string | null
submitted_at: string | null
submitter_avatar_url: string | null
submitter_display_name: string | null
submitter_id: string | null
submitter_username: string | null
updated_at: string | null
}
Relationships: [
{
foreignKeyName: "content_submissions_reviewer_id_fkey"
columns: ["approver_id"]
isOneToOne: false
referencedRelation: "filtered_profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "content_submissions_reviewer_id_fkey"
columns: ["approver_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "content_submissions_user_id_fkey"
columns: ["submitter_id"]
isOneToOne: false
referencedRelation: "filtered_profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "content_submissions_user_id_fkey"
columns: ["submitter_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
{
foreignKeyName: "submission_items_submission_id_fkey"
columns: ["submission_id"]
isOneToOne: false
referencedRelation: "content_submissions"
referencedColumns: ["id"]
},
{
foreignKeyName: "submission_items_submission_id_fkey"
columns: ["submission_id"]
isOneToOne: false
referencedRelation: "moderation_queue_with_entities"
referencedColumns: ["id"]
},
]
}
data_retention_stats: {
Row: {
last_30_days: number | null
@@ -6831,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 }
@@ -7066,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
}
@@ -7099,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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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