mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-29 20:47:05 -05:00
Compare commits
53 Commits
3797e34e0b
...
edit/edt-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8bea4b798 | ||
|
|
250e7c488a | ||
|
|
46c08e10e8 | ||
|
|
b22546e7f2 | ||
|
|
7b0825e772 | ||
|
|
1a57b4f33f | ||
|
|
4c7731410f | ||
|
|
beacf481d8 | ||
|
|
00054f817d | ||
|
|
d18632c2b2 | ||
|
|
09c320f508 | ||
|
|
8422bc378f | ||
|
|
5531376edf | ||
|
|
b6d1b99f2b | ||
|
|
d24de6a9e6 | ||
|
|
c3cab84132 | ||
|
|
ab9d424240 | ||
|
|
617e079c5a | ||
|
|
3cb2c39acf | ||
|
|
3867d30aac | ||
|
|
fdfa1739e5 | ||
|
|
361231bfac | ||
|
|
2ccfe8c48a | ||
|
|
fd4e21734f | ||
|
|
9bab4358e3 | ||
|
|
5b5bd4d62e | ||
|
|
d435bda06a | ||
|
|
888ef0224a | ||
|
|
78e29f9e49 | ||
|
|
842861af8c | ||
|
|
348ab23d26 | ||
|
|
b58a0a7741 | ||
|
|
e2ee11b9f5 | ||
|
|
2468d3cc18 | ||
|
|
f4300de738 | ||
|
|
92e93bfc9d | ||
|
|
7d085a0702 | ||
|
|
6fef107728 | ||
|
|
42f26acb49 | ||
|
|
985454f0d9 | ||
|
|
67ce8b5a88 | ||
|
|
99c8c94e47 | ||
|
|
9a3fbb2f78 | ||
|
|
2f579b08ba | ||
|
|
dce8747651 | ||
|
|
d0c613031e | ||
|
|
9ee84b31ff | ||
|
|
96b7594738 | ||
|
|
8ee548fd27 | ||
|
|
de921a5fcf | ||
|
|
4040fd783e | ||
|
|
afe7a93f69 | ||
|
|
fa57d497af |
183
docs/LOCATION_FIX_SUMMARY.md
Normal file
183
docs/LOCATION_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Location Handling Fix - Complete Summary
|
||||||
|
|
||||||
|
## Problem Identified
|
||||||
|
|
||||||
|
Parks were being created without location data due to a critical bug in the approval pipeline. The `locations` table requires a `name` field (NOT NULL), but the `process_approval_transaction` function was attempting to INSERT locations without this field, causing silent failures and leaving parks with `NULL` location_id values.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The function was:
|
||||||
|
1. ✅ Correctly JOINing `park_submission_locations` table
|
||||||
|
2. ✅ Fetching location fields like `country`, `city`, `latitude`, etc.
|
||||||
|
3. ❌ **NOT** fetching the `name` or `display_name` fields
|
||||||
|
4. ❌ **NOT** including `name` field in the INSERT statement
|
||||||
|
|
||||||
|
This caused PostgreSQL to reject the INSERT (violating NOT NULL constraint), but since there was no explicit error handling for this specific failure, the park was still created with `location_id = NULL`.
|
||||||
|
|
||||||
|
## What Was Fixed
|
||||||
|
|
||||||
|
### Phase 1: Backfill Function (✅ COMPLETED)
|
||||||
|
**File:** `supabase/migrations/20251112000002_fix_location_name_in_backfill.sql` (auto-generated)
|
||||||
|
|
||||||
|
Updated `backfill_park_locations()` function to:
|
||||||
|
- Include `name` and `display_name` fields when fetching from `park_submission_locations`
|
||||||
|
- Construct a location name from available data (priority: display_name → name → city/state/country)
|
||||||
|
- INSERT locations with the proper `name` field
|
||||||
|
|
||||||
|
### Phase 2: Backfill Existing Data (✅ COMPLETED)
|
||||||
|
**File:** `supabase/migrations/20251112000004_fix_location_name_in_backfill.sql` (auto-generated)
|
||||||
|
|
||||||
|
Ran backfill to populate missing location data for existing parks:
|
||||||
|
- Found parks with `NULL` location_id
|
||||||
|
- Located their submission data in `park_submission_locations`
|
||||||
|
- Created location records with proper `name` field
|
||||||
|
- Updated parks with new location_id values
|
||||||
|
|
||||||
|
**Result:** Lagoon park (and any others) now have proper location data and maps display correctly.
|
||||||
|
|
||||||
|
### Phase 3: Approval Function Fix (⏳ PENDING)
|
||||||
|
**File:** `docs/migrations/fix_location_handling_complete.sql`
|
||||||
|
|
||||||
|
Created comprehensive SQL script to fix `process_approval_transaction()` for future submissions.
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
1. Added to SELECT clause (line ~108):
|
||||||
|
```sql
|
||||||
|
psl.name as park_location_name,
|
||||||
|
psl.display_name as park_location_display_name,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Updated CREATE action location INSERT (line ~204):
|
||||||
|
```sql
|
||||||
|
v_location_name := COALESCE(
|
||||||
|
v_item.park_location_display_name,
|
||||||
|
v_item.park_location_name,
|
||||||
|
CONCAT_WS(', ', city, state, country)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO locations (name, country, ...)
|
||||||
|
VALUES (v_location_name, v_item.park_location_country, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Updated UPDATE action location INSERT (line ~454):
|
||||||
|
```sql
|
||||||
|
-- Same logic as CREATE action
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Apply the Approval Function Fix
|
||||||
|
|
||||||
|
The complete SQL script is ready in `docs/migrations/fix_location_handling_complete.sql`.
|
||||||
|
|
||||||
|
### Option 1: Via Supabase SQL Editor (Recommended)
|
||||||
|
1. Go to [Supabase SQL Editor](https://supabase.com/dashboard/project/ydvtmnrszybqnbcqbdcy/sql/new)
|
||||||
|
2. Copy the contents of `docs/migrations/fix_location_handling_complete.sql`
|
||||||
|
3. Paste and execute the SQL
|
||||||
|
4. Verify success by checking the function exists
|
||||||
|
|
||||||
|
### Option 2: Via Migration Tool (Later)
|
||||||
|
The migration can be split into smaller chunks if needed, but the complete file is ready for manual application.
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
### 1. Verify Existing Parks Have Locations
|
||||||
|
```sql
|
||||||
|
SELECT p.name, p.slug, p.location_id, l.name as location_name
|
||||||
|
FROM parks p
|
||||||
|
LEFT JOIN locations l ON p.location_id = l.id
|
||||||
|
WHERE p.slug = 'lagoon';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Result:** Location data should be populated ✅
|
||||||
|
|
||||||
|
### 2. Test New Park Submission (After Applying Fix)
|
||||||
|
1. Create a new park submission with location data
|
||||||
|
2. Submit for moderation
|
||||||
|
3. Approve the submission
|
||||||
|
4. Verify the park has a non-NULL location_id
|
||||||
|
5. Check the locations table has the proper name field
|
||||||
|
6. Verify the map displays on the park detail page
|
||||||
|
|
||||||
|
### 3. Test Park Update with Location Change
|
||||||
|
1. Edit an existing park and change its location
|
||||||
|
2. Submit for moderation
|
||||||
|
3. Approve the update
|
||||||
|
4. Verify a new location record was created with proper name
|
||||||
|
5. Verify the park's location_id was updated
|
||||||
|
|
||||||
|
## Database Schema Context
|
||||||
|
|
||||||
|
### locations Table Structure
|
||||||
|
```sql
|
||||||
|
- id: uuid (PK)
|
||||||
|
- name: text (NOT NULL) ← This was the missing field
|
||||||
|
- country: text
|
||||||
|
- state_province: text
|
||||||
|
- city: text
|
||||||
|
- street_address: text
|
||||||
|
- postal_code: text
|
||||||
|
- latitude: numeric
|
||||||
|
- longitude: numeric
|
||||||
|
- timezone: text
|
||||||
|
- created_at: timestamp with time zone
|
||||||
|
```
|
||||||
|
|
||||||
|
### park_submission_locations Table Structure
|
||||||
|
```sql
|
||||||
|
- id: uuid (PK)
|
||||||
|
- park_submission_id: uuid (FK)
|
||||||
|
- name: text ← We weren't fetching this
|
||||||
|
- display_name: text ← We weren't fetching this
|
||||||
|
- country: text
|
||||||
|
- state_province: text
|
||||||
|
- city: text
|
||||||
|
- street_address: text
|
||||||
|
- postal_code: text
|
||||||
|
- latitude: numeric
|
||||||
|
- longitude: numeric
|
||||||
|
- timezone: text
|
||||||
|
- created_at: timestamp with time zone
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact Assessment
|
||||||
|
|
||||||
|
### Before Fix
|
||||||
|
- ❌ Parks created without location data (location_id = NULL)
|
||||||
|
- ❌ Maps not displaying on park detail pages
|
||||||
|
- ❌ Location-based features not working
|
||||||
|
- ❌ Silent failures in approval pipeline
|
||||||
|
|
||||||
|
### After Complete Fix
|
||||||
|
- ✅ All existing parks have location data (backfilled)
|
||||||
|
- ✅ Maps display correctly on park detail pages
|
||||||
|
- ✅ Future park submissions will have locations created properly
|
||||||
|
- ✅ Park updates with location changes work correctly
|
||||||
|
- ✅ No more silent failures in the pipeline
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
1. `docs/migrations/fix_location_handling_complete.sql` - Complete SQL script for approval function fix
|
||||||
|
2. `docs/LOCATION_FIX_SUMMARY.md` - This document
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Immediate:** Apply the fix from `docs/migrations/fix_location_handling_complete.sql`
|
||||||
|
2. **Testing:** Run verification steps above
|
||||||
|
3. **Monitoring:** Watch for any location-related errors in production
|
||||||
|
4. **Documentation:** Update team on the fix and new behavior
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
This fix ensures compliance with the "Sacred Pipeline" architecture documented in `docs/SUBMISSION_FLOW.md`. All location data flows through:
|
||||||
|
1. User form input
|
||||||
|
2. Submission to `park_submission_locations` table
|
||||||
|
3. Moderation queue review
|
||||||
|
4. Approval via `process_approval_transaction` function
|
||||||
|
5. Location creation in `locations` table
|
||||||
|
6. Park creation/update with proper location_id reference
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
- The `display_name` field in `park_submission_locations` is used for human-readable location labels (e.g., "375, Lagoon Drive, Farmington, Davis County, Utah, 84025, United States")
|
||||||
|
- The `name` field in `locations` must be populated for the INSERT to succeed
|
||||||
|
- If neither display_name nor name is provided, we construct it from city/state/country as a fallback
|
||||||
|
- This pattern should be applied to any other entities that use location data in the future
|
||||||
439
docs/migrations/fix_location_handling_complete.sql
Normal file
439
docs/migrations/fix_location_handling_complete.sql
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- COMPLETE FIX: Location Name Handling in Approval Pipeline
|
||||||
|
-- ============================================================================
|
||||||
|
--
|
||||||
|
-- PURPOSE:
|
||||||
|
-- This migration fixes the process_approval_transaction function to properly
|
||||||
|
-- handle location names when creating parks. Without this fix, locations are
|
||||||
|
-- created without the 'name' field, causing silent failures and parks end up
|
||||||
|
-- with NULL location_id values.
|
||||||
|
--
|
||||||
|
-- WHAT THIS FIXES:
|
||||||
|
-- 1. Adds park_location_name and park_location_display_name to the SELECT
|
||||||
|
-- 2. Creates locations with proper name field during CREATE actions
|
||||||
|
-- 3. Creates locations with proper name field during UPDATE actions
|
||||||
|
-- 4. Falls back to constructing name from city/state/country if not provided
|
||||||
|
--
|
||||||
|
-- TESTING:
|
||||||
|
-- After applying, test by:
|
||||||
|
-- 1. Creating a new park submission with location data
|
||||||
|
-- 2. Approving the submission
|
||||||
|
-- 3. Verifying the park has a location_id set
|
||||||
|
-- 4. Checking the locations table has a record with proper name field
|
||||||
|
--
|
||||||
|
-- DEPLOYMENT:
|
||||||
|
-- This can be run manually via Supabase SQL Editor or applied as a migration
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||||
|
p_submission_id UUID,
|
||||||
|
p_item_ids UUID[],
|
||||||
|
p_moderator_id UUID,
|
||||||
|
p_submitter_id UUID,
|
||||||
|
p_request_id TEXT DEFAULT NULL,
|
||||||
|
p_trace_id TEXT DEFAULT NULL,
|
||||||
|
p_parent_span_id TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_start_time TIMESTAMPTZ;
|
||||||
|
v_result JSONB;
|
||||||
|
v_item RECORD;
|
||||||
|
v_entity_id UUID;
|
||||||
|
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||||
|
v_final_status TEXT;
|
||||||
|
v_all_approved BOOLEAN := TRUE;
|
||||||
|
v_some_approved BOOLEAN := FALSE;
|
||||||
|
v_items_processed INTEGER := 0;
|
||||||
|
v_span_id TEXT;
|
||||||
|
v_resolved_park_id UUID;
|
||||||
|
v_resolved_manufacturer_id UUID;
|
||||||
|
v_resolved_ride_model_id UUID;
|
||||||
|
v_resolved_operator_id UUID;
|
||||||
|
v_resolved_property_owner_id UUID;
|
||||||
|
v_resolved_location_id UUID;
|
||||||
|
v_location_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
v_start_time := clock_timestamp();
|
||||||
|
v_span_id := gen_random_uuid()::text;
|
||||||
|
|
||||||
|
IF p_trace_id IS NOT NULL THEN
|
||||||
|
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||||
|
v_span_id, p_trace_id, p_parent_span_id, EXTRACT(EPOCH FROM v_start_time) * 1000, p_submission_id, array_length(p_item_ids, 1);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE '[%] Starting atomic approval transaction for submission %', COALESCE(p_request_id, 'NO_REQUEST_ID'), p_submission_id;
|
||||||
|
|
||||||
|
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||||
|
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||||
|
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM content_submissions
|
||||||
|
WHERE id = p_submission_id AND (assigned_to = p_moderator_id OR assigned_to IS NULL) AND status IN ('pending', 'partially_approved')
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- CRITICAL FIX: Added park_location_name and park_location_display_name
|
||||||
|
-- ========================================================================
|
||||||
|
FOR v_item IN
|
||||||
|
SELECT si.*,
|
||||||
|
ps.name as park_name, ps.slug as park_slug, ps.description as park_description, ps.park_type, ps.status as park_status,
|
||||||
|
ps.location_id, ps.operator_id, ps.property_owner_id, ps.opening_date as park_opening_date, ps.closing_date as park_closing_date,
|
||||||
|
ps.opening_date_precision as park_opening_date_precision, ps.closing_date_precision as park_closing_date_precision,
|
||||||
|
ps.website_url as park_website_url, ps.phone as park_phone, ps.email as park_email,
|
||||||
|
ps.banner_image_url as park_banner_image_url, ps.banner_image_id as park_banner_image_id,
|
||||||
|
ps.card_image_url as park_card_image_url, ps.card_image_id as park_card_image_id,
|
||||||
|
psl.name as park_location_name, psl.display_name as park_location_display_name,
|
||||||
|
psl.country as park_location_country, psl.state_province as park_location_state, psl.city as park_location_city,
|
||||||
|
psl.street_address as park_location_street, psl.postal_code as park_location_postal,
|
||||||
|
psl.latitude as park_location_lat, psl.longitude as park_location_lng, psl.timezone as park_location_timezone,
|
||||||
|
rs.name as ride_name, rs.slug as ride_slug, rs.park_id as ride_park_id, rs.category as ride_category, rs.status as ride_status,
|
||||||
|
rs.manufacturer_id, rs.ride_model_id, rs.opening_date as ride_opening_date, rs.closing_date as ride_closing_date,
|
||||||
|
rs.opening_date_precision as ride_opening_date_precision, rs.closing_date_precision as ride_closing_date_precision,
|
||||||
|
rs.description as ride_description, rs.banner_image_url as ride_banner_image_url, rs.banner_image_id as ride_banner_image_id,
|
||||||
|
rs.card_image_url as ride_card_image_url, rs.card_image_id as ride_card_image_id,
|
||||||
|
cs.name as company_name, cs.slug as company_slug, cs.description as company_description, cs.company_type,
|
||||||
|
cs.website_url as company_website_url, cs.founded_year, cs.founded_date, cs.founded_date_precision,
|
||||||
|
cs.headquarters_location, cs.logo_url, cs.person_type,
|
||||||
|
cs.banner_image_url as company_banner_image_url, cs.banner_image_id as company_banner_image_id,
|
||||||
|
cs.card_image_url as company_card_image_url, cs.card_image_id as company_card_image_id,
|
||||||
|
rms.name as ride_model_name, rms.slug as ride_model_slug, rms.manufacturer_id as ride_model_manufacturer_id,
|
||||||
|
rms.category as ride_model_category, rms.description as ride_model_description,
|
||||||
|
rms.banner_image_url as ride_model_banner_image_url, rms.banner_image_id as ride_model_banner_image_id,
|
||||||
|
rms.card_image_url as ride_model_card_image_url, rms.card_image_id as ride_model_card_image_id,
|
||||||
|
phs.entity_id as photo_entity_id, phs.entity_type as photo_entity_type, phs.title as photo_title
|
||||||
|
FROM submission_items si
|
||||||
|
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||||
|
LEFT JOIN park_submission_locations psl ON ps.id = psl.park_submission_id
|
||||||
|
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||||
|
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||||
|
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||||
|
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
|
||||||
|
WHERE si.id = ANY(p_item_ids)
|
||||||
|
ORDER BY si.order_index, si.created_at
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
v_items_processed := v_items_processed + 1;
|
||||||
|
v_entity_id := NULL;
|
||||||
|
v_resolved_park_id := NULL; v_resolved_manufacturer_id := NULL; v_resolved_ride_model_id := NULL;
|
||||||
|
v_resolved_operator_id := NULL; v_resolved_property_owner_id := NULL; v_resolved_location_id := NULL;
|
||||||
|
|
||||||
|
IF p_trace_id IS NOT NULL THEN
|
||||||
|
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||||
|
p_trace_id, v_span_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_item.id, v_item.item_type, v_item.action_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_item.action_type = 'create' THEN
|
||||||
|
IF v_item.item_type = 'park' THEN
|
||||||
|
-- ========================================================================
|
||||||
|
-- CRITICAL FIX: Create location with name field
|
||||||
|
-- ========================================================================
|
||||||
|
IF v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL THEN
|
||||||
|
-- Construct a name for the location, prioritizing display_name, then name, then city/state/country
|
||||||
|
v_location_name := COALESCE(
|
||||||
|
v_item.park_location_display_name,
|
||||||
|
v_item.park_location_name,
|
||||||
|
CONCAT_WS(', ',
|
||||||
|
NULLIF(v_item.park_location_city, ''),
|
||||||
|
NULLIF(v_item.park_location_state, ''),
|
||||||
|
NULLIF(v_item.park_location_country, '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
|
||||||
|
VALUES (
|
||||||
|
v_location_name,
|
||||||
|
v_item.park_location_country,
|
||||||
|
v_item.park_location_state,
|
||||||
|
v_item.park_location_city,
|
||||||
|
v_item.park_location_street,
|
||||||
|
v_item.park_location_postal,
|
||||||
|
v_item.park_location_lat,
|
||||||
|
v_item.park_location_lng,
|
||||||
|
v_item.park_location_timezone
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_resolved_location_id;
|
||||||
|
|
||||||
|
RAISE NOTICE '[%] Created location % (name: %) for park submission',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Resolve temporary references
|
||||||
|
IF v_item.operator_id IS NULL THEN
|
||||||
|
SELECT approved_entity_id INTO v_resolved_operator_id FROM submission_items
|
||||||
|
WHERE submission_id = p_submission_id AND item_type IN ('operator', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_item.property_owner_id IS NULL THEN
|
||||||
|
SELECT approved_entity_id INTO v_resolved_property_owner_id FROM submission_items
|
||||||
|
WHERE submission_id = p_submission_id AND item_type IN ('property_owner', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO parks (name, slug, description, park_type, status, location_id, operator_id, property_owner_id,
|
||||||
|
opening_date, closing_date, opening_date_precision, closing_date_precision, website_url, phone, email,
|
||||||
|
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||||
|
VALUES (
|
||||||
|
v_item.park_name, v_item.park_slug, v_item.park_description, v_item.park_type, v_item.park_status,
|
||||||
|
COALESCE(v_resolved_location_id, v_item.location_id),
|
||||||
|
COALESCE(v_item.operator_id, v_resolved_operator_id),
|
||||||
|
COALESCE(v_item.property_owner_id, v_resolved_property_owner_id),
|
||||||
|
v_item.park_opening_date, v_item.park_closing_date,
|
||||||
|
v_item.park_opening_date_precision, v_item.park_closing_date_precision,
|
||||||
|
v_item.park_website_url, v_item.park_phone, v_item.park_email,
|
||||||
|
v_item.park_banner_image_url, v_item.park_banner_image_id,
|
||||||
|
v_item.park_card_image_url, v_item.park_card_image_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type = 'ride' THEN
|
||||||
|
IF v_item.ride_park_id IS NULL THEN
|
||||||
|
SELECT approved_entity_id INTO v_resolved_park_id FROM submission_items
|
||||||
|
WHERE submission_id = p_submission_id AND item_type = 'park' AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_item.manufacturer_id IS NULL THEN
|
||||||
|
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
|
||||||
|
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_item.ride_model_id IS NULL THEN
|
||||||
|
SELECT approved_entity_id INTO v_resolved_ride_model_id FROM submission_items
|
||||||
|
WHERE submission_id = p_submission_id AND item_type = 'ride_model' AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO rides (name, slug, park_id, category, status, manufacturer_id, ride_model_id,
|
||||||
|
opening_date, closing_date, opening_date_precision, closing_date_precision, description,
|
||||||
|
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||||
|
VALUES (
|
||||||
|
v_item.ride_name, v_item.ride_slug, COALESCE(v_item.ride_park_id, v_resolved_park_id),
|
||||||
|
v_item.ride_category, v_item.ride_status,
|
||||||
|
COALESCE(v_item.manufacturer_id, v_resolved_manufacturer_id),
|
||||||
|
COALESCE(v_item.ride_model_id, v_resolved_ride_model_id),
|
||||||
|
v_item.ride_opening_date, v_item.ride_closing_date,
|
||||||
|
v_item.ride_opening_date_precision, v_item.ride_closing_date_precision,
|
||||||
|
v_item.ride_description, v_item.ride_banner_image_url, v_item.ride_banner_image_id,
|
||||||
|
v_item.ride_card_image_url, v_item.ride_card_image_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
IF v_entity_id IS NOT NULL AND v_item.ride_submission_id IS NOT NULL THEN
|
||||||
|
INSERT INTO ride_technical_specifications (ride_id, specification_key, specification_value, unit, display_order)
|
||||||
|
SELECT v_entity_id, specification_key, specification_value, unit, display_order
|
||||||
|
FROM ride_technical_specifications WHERE ride_id = v_item.ride_submission_id;
|
||||||
|
|
||||||
|
INSERT INTO ride_coaster_stats (ride_id, stat_key, stat_value, unit, display_order)
|
||||||
|
SELECT v_entity_id, stat_key, stat_value, unit, display_order
|
||||||
|
FROM ride_coaster_stats WHERE ride_id = v_item.ride_submission_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||||
|
INSERT INTO companies (name, slug, description, company_type, person_type, website_url, founded_year,
|
||||||
|
founded_date, founded_date_precision, headquarters_location, logo_url,
|
||||||
|
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||||
|
VALUES (
|
||||||
|
v_item.company_name, v_item.company_slug, v_item.company_description, v_item.company_type,
|
||||||
|
v_item.person_type, v_item.company_website_url, v_item.founded_year,
|
||||||
|
v_item.founded_date, v_item.founded_date_precision, v_item.headquarters_location, v_item.logo_url,
|
||||||
|
v_item.company_banner_image_url, v_item.company_banner_image_id,
|
||||||
|
v_item.company_card_image_url, v_item.company_card_image_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type = 'ride_model' THEN
|
||||||
|
IF v_item.ride_model_manufacturer_id IS NULL THEN
|
||||||
|
SELECT approved_entity_id INTO v_resolved_manufacturer_id FROM submission_items
|
||||||
|
WHERE submission_id = p_submission_id AND item_type IN ('manufacturer', 'company') AND approved_entity_id IS NOT NULL LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO ride_models (name, slug, manufacturer_id, category, description,
|
||||||
|
banner_image_url, banner_image_id, card_image_url, card_image_id)
|
||||||
|
VALUES (
|
||||||
|
v_item.ride_model_name, v_item.ride_model_slug,
|
||||||
|
COALESCE(v_item.ride_model_manufacturer_id, v_resolved_manufacturer_id),
|
||||||
|
v_item.ride_model_category, v_item.ride_model_description,
|
||||||
|
v_item.ride_model_banner_image_url, v_item.ride_model_banner_image_id,
|
||||||
|
v_item.ride_model_card_image_url, v_item.ride_model_card_image_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type = 'photo' THEN
|
||||||
|
INSERT INTO entity_photos (entity_id, entity_type, title, photo_submission_id)
|
||||||
|
VALUES (v_item.photo_entity_id, v_item.photo_entity_type, v_item.photo_title, v_item.photo_submission_id)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unknown item type for create: %', v_item.item_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ELSIF v_item.action_type = 'update' THEN
|
||||||
|
IF v_item.entity_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Update action requires entity_id';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_item.item_type = 'park' THEN
|
||||||
|
-- ========================================================================
|
||||||
|
-- CRITICAL FIX: Create location with name field for updates too
|
||||||
|
-- ========================================================================
|
||||||
|
IF v_item.location_id IS NULL AND (v_item.park_location_country IS NOT NULL OR v_item.park_location_city IS NOT NULL) THEN
|
||||||
|
v_location_name := COALESCE(
|
||||||
|
v_item.park_location_display_name,
|
||||||
|
v_item.park_location_name,
|
||||||
|
CONCAT_WS(', ',
|
||||||
|
NULLIF(v_item.park_location_city, ''),
|
||||||
|
NULLIF(v_item.park_location_state, ''),
|
||||||
|
NULLIF(v_item.park_location_country, '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO locations (name, country, state_province, city, street_address, postal_code, latitude, longitude, timezone)
|
||||||
|
VALUES (
|
||||||
|
v_location_name,
|
||||||
|
v_item.park_location_country,
|
||||||
|
v_item.park_location_state,
|
||||||
|
v_item.park_location_city,
|
||||||
|
v_item.park_location_street,
|
||||||
|
v_item.park_location_postal,
|
||||||
|
v_item.park_location_lat,
|
||||||
|
v_item.park_location_lng,
|
||||||
|
v_item.park_location_timezone
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_resolved_location_id;
|
||||||
|
|
||||||
|
RAISE NOTICE '[%] Created location % (name: %) for park update',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'), v_resolved_location_id, v_location_name;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE parks SET
|
||||||
|
name = v_item.park_name, slug = v_item.park_slug, description = v_item.park_description,
|
||||||
|
park_type = v_item.park_type, status = v_item.park_status,
|
||||||
|
location_id = COALESCE(v_resolved_location_id, v_item.location_id),
|
||||||
|
operator_id = v_item.operator_id, property_owner_id = v_item.property_owner_id,
|
||||||
|
opening_date = v_item.park_opening_date, closing_date = v_item.park_closing_date,
|
||||||
|
opening_date_precision = v_item.park_opening_date_precision,
|
||||||
|
closing_date_precision = v_item.park_closing_date_precision,
|
||||||
|
website_url = v_item.park_website_url, phone = v_item.park_phone, email = v_item.park_email,
|
||||||
|
banner_image_url = v_item.park_banner_image_url, banner_image_id = v_item.park_banner_image_id,
|
||||||
|
card_image_url = v_item.park_card_image_url, card_image_id = v_item.park_card_image_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.entity_id;
|
||||||
|
v_entity_id := v_item.entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type = 'ride' THEN
|
||||||
|
UPDATE rides SET
|
||||||
|
name = v_item.ride_name, slug = v_item.ride_slug, park_id = v_item.ride_park_id,
|
||||||
|
category = v_item.ride_category, status = v_item.ride_status,
|
||||||
|
manufacturer_id = v_item.manufacturer_id, ride_model_id = v_item.ride_model_id,
|
||||||
|
opening_date = v_item.ride_opening_date, closing_date = v_item.ride_closing_date,
|
||||||
|
opening_date_precision = v_item.ride_opening_date_precision,
|
||||||
|
closing_date_precision = v_item.ride_closing_date_precision,
|
||||||
|
description = v_item.ride_description,
|
||||||
|
banner_image_url = v_item.ride_banner_image_url, banner_image_id = v_item.ride_banner_image_id,
|
||||||
|
card_image_url = v_item.ride_card_image_url, card_image_id = v_item.ride_card_image_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.entity_id;
|
||||||
|
v_entity_id := v_item.entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type IN ('company', 'manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||||
|
UPDATE companies SET
|
||||||
|
name = v_item.company_name, slug = v_item.company_slug, description = v_item.company_description,
|
||||||
|
company_type = v_item.company_type, person_type = v_item.person_type,
|
||||||
|
website_url = v_item.company_website_url, founded_year = v_item.founded_year,
|
||||||
|
founded_date = v_item.founded_date, founded_date_precision = v_item.founded_date_precision,
|
||||||
|
headquarters_location = v_item.headquarters_location, logo_url = v_item.logo_url,
|
||||||
|
banner_image_url = v_item.company_banner_image_url, banner_image_id = v_item.company_banner_image_id,
|
||||||
|
card_image_url = v_item.company_card_image_url, card_image_id = v_item.company_card_image_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.entity_id;
|
||||||
|
v_entity_id := v_item.entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type = 'ride_model' THEN
|
||||||
|
UPDATE ride_models SET
|
||||||
|
name = v_item.ride_model_name, slug = v_item.ride_model_slug,
|
||||||
|
manufacturer_id = v_item.ride_model_manufacturer_id,
|
||||||
|
category = v_item.ride_model_category, description = v_item.ride_model_description,
|
||||||
|
banner_image_url = v_item.ride_model_banner_image_url, banner_image_id = v_item.ride_model_banner_image_id,
|
||||||
|
card_image_url = v_item.ride_model_card_image_url, card_image_id = v_item.ride_model_card_image_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_item.entity_id;
|
||||||
|
v_entity_id := v_item.entity_id;
|
||||||
|
|
||||||
|
ELSIF v_item.item_type = 'photo' THEN
|
||||||
|
UPDATE entity_photos SET title = v_item.photo_title, updated_at = now()
|
||||||
|
WHERE id = v_item.entity_id;
|
||||||
|
v_entity_id := v_item.entity_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unknown item type for update: %', v_item.item_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE submission_items SET approved_entity_id = v_entity_id, approved_at = now(), status = 'approved'
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
|
||||||
|
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||||
|
'item_id', v_item.id, 'status', 'approved', 'entity_id', v_entity_id
|
||||||
|
));
|
||||||
|
v_some_approved := TRUE;
|
||||||
|
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
|
||||||
|
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||||
|
'item_id', v_item.id, 'status', 'failed', 'error', SQLERRM
|
||||||
|
));
|
||||||
|
v_all_approved := FALSE;
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF v_all_approved THEN
|
||||||
|
v_final_status := 'approved';
|
||||||
|
ELSIF v_some_approved THEN
|
||||||
|
v_final_status := 'partially_approved';
|
||||||
|
ELSE
|
||||||
|
v_final_status := 'rejected';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE content_submissions SET
|
||||||
|
status = v_final_status,
|
||||||
|
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
|
||||||
|
reviewer_id = p_moderator_id,
|
||||||
|
reviewed_at = now()
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
|
||||||
|
IF p_trace_id IS NOT NULL THEN
|
||||||
|
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
|
||||||
|
v_span_id, p_trace_id, EXTRACT(EPOCH FROM clock_timestamp()) * 1000, v_items_processed, v_final_status;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'success', v_all_approved,
|
||||||
|
'status', v_final_status,
|
||||||
|
'items_processed', v_items_processed,
|
||||||
|
'results', v_approval_results,
|
||||||
|
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION process_approval_transaction TO authenticated;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION process_approval_transaction IS
|
||||||
|
'✅ FIXED 2025-11-12: Now properly creates location records with name field during park approval/update.
|
||||||
|
This prevents parks from being created with NULL location_id values due to silent INSERT failures.';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- END OF MIGRATION
|
||||||
|
-- ============================================================================
|
||||||
29
src/App.tsx
29
src/App.tsx
@@ -24,6 +24,7 @@ import { ResilienceProvider } from "@/components/layout/ResilienceProvider";
|
|||||||
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
|
import { useAdminRoutePreload } from "@/hooks/useAdminRoutePreload";
|
||||||
import { useVersionCheck } from "@/hooks/useVersionCheck";
|
import { useVersionCheck } from "@/hooks/useVersionCheck";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PageTransition } from "@/components/layout/PageTransition";
|
||||||
|
|
||||||
// Core routes (eager-loaded for best UX)
|
// Core routes (eager-loaded for best UX)
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
@@ -70,6 +71,7 @@ const AdminUsers = lazy(() => import("./pages/AdminUsers"));
|
|||||||
const AdminBlog = lazy(() => import("./pages/AdminBlog"));
|
const AdminBlog = lazy(() => import("./pages/AdminBlog"));
|
||||||
const AdminSettings = lazy(() => import("./pages/AdminSettings"));
|
const AdminSettings = lazy(() => import("./pages/AdminSettings"));
|
||||||
const AdminDatabaseStats = lazy(() => import("./pages/AdminDatabaseStats"));
|
const AdminDatabaseStats = lazy(() => import("./pages/AdminDatabaseStats"));
|
||||||
|
const DatabaseMaintenance = lazy(() => import("./pages/admin/DatabaseMaintenance"));
|
||||||
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
|
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
|
||||||
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
|
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
|
||||||
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
|
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
|
||||||
@@ -77,6 +79,7 @@ const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
|||||||
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
||||||
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
||||||
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
||||||
|
const ApprovalHistory = lazy(() => import("./pages/admin/ApprovalHistory"));
|
||||||
|
|
||||||
// User routes (lazy-loaded)
|
// User routes (lazy-loaded)
|
||||||
const Profile = lazy(() => import("./pages/Profile"));
|
const Profile = lazy(() => import("./pages/Profile"));
|
||||||
@@ -163,8 +166,9 @@ function AppContent(): React.JSX.Element {
|
|||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<RouteErrorBoundary>
|
<PageTransition>
|
||||||
<Routes>
|
<RouteErrorBoundary>
|
||||||
|
<Routes>
|
||||||
{/* Core routes - eager loaded */}
|
{/* Core routes - eager loaded */}
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/parks" element={<Parks />} />
|
<Route path="/parks" element={<Parks />} />
|
||||||
@@ -384,7 +388,15 @@ function AppContent(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/error-lookup"
|
path="/admin/approval-history"
|
||||||
|
element={
|
||||||
|
<AdminErrorBoundary section="Approval History">
|
||||||
|
<ApprovalHistory />
|
||||||
|
</AdminErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/error-lookup"
|
||||||
element={
|
element={
|
||||||
<AdminErrorBoundary section="Error Lookup">
|
<AdminErrorBoundary section="Error Lookup">
|
||||||
<ErrorLookup />
|
<ErrorLookup />
|
||||||
@@ -423,6 +435,14 @@ function AppContent(): React.JSX.Element {
|
|||||||
</AdminErrorBoundary>
|
</AdminErrorBoundary>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/database-maintenance"
|
||||||
|
element={
|
||||||
|
<AdminErrorBoundary section="Database Maintenance">
|
||||||
|
<DatabaseMaintenance />
|
||||||
|
</AdminErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Utility routes - lazy loaded */}
|
{/* Utility routes - lazy loaded */}
|
||||||
<Route path="/force-logout" element={<ForceLogout />} />
|
<Route path="/force-logout" element={<ForceLogout />} />
|
||||||
@@ -434,7 +454,8 @@ function AppContent(): React.JSX.Element {
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
</Suspense>
|
</PageTransition>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { useUserRole } from '@/hooks/useUserRole';
|
|||||||
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
||||||
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import type { UploadedImage } from '@/types/company';
|
import type { UploadedImage } from '@/types/company';
|
||||||
|
|
||||||
// Zod output type (after transformation)
|
// Zod output type (after transformation)
|
||||||
@@ -73,7 +74,7 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(async (data) => {
|
<form onSubmit={handleSubmit(async (data) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error('You must be logged in to submit');
|
formToasts.error.generic('You must be logged in to submit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +94,11 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
|||||||
|
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
|
|
||||||
// Only show success toast and close if not editing through moderation queue
|
// Show success toast
|
||||||
if (!initialData?.id) {
|
if (initialData?.id) {
|
||||||
toast.success('Designer submitted for review');
|
formToasts.success.update('Designer', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Designer', data.name);
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -104,6 +107,9 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
|||||||
metadata: { companyName: data.name }
|
metadata: { companyName: data.name }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
|||||||
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
||||||
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
|
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||||
import type { UploadedImage } from '@/types/company';
|
import type { UploadedImage } from '@/types/company';
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
website_url: initialData?.website_url || '',
|
website_url: initialData?.website_url || '',
|
||||||
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
|
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
|
||||||
founded_date: initialData?.founded_date || (initialData?.founded_year ? `${initialData.founded_year}-01-01` : undefined),
|
founded_date: initialData?.founded_date || (initialData?.founded_year ? `${initialData.founded_year}-01-01` : undefined),
|
||||||
founded_date_precision: initialData?.founded_date_precision || (initialData?.founded_year ? ('year' as const) : ('day' as const)),
|
founded_date_precision: initialData?.founded_date_precision || (initialData?.founded_year ? ('year' as const) : ('exact' as const)),
|
||||||
headquarters_location: initialData?.headquarters_location || '',
|
headquarters_location: initialData?.headquarters_location || '',
|
||||||
source_url: initialData?.source_url || '',
|
source_url: initialData?.source_url || '',
|
||||||
submission_notes: initialData?.submission_notes || '',
|
submission_notes: initialData?.submission_notes || '',
|
||||||
@@ -77,7 +78,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(async (data) => {
|
<form onSubmit={handleSubmit(async (data) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error('You must be logged in to submit');
|
formToasts.error.generic('You must be logged in to submit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,9 +96,11 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
|
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
|
|
||||||
// Only show success toast and close if not editing through moderation queue
|
// Show success toast
|
||||||
if (!initialData?.id) {
|
if (initialData?.id) {
|
||||||
toast.success('Manufacturer submitted for review');
|
formToasts.success.update('Manufacturer', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Manufacturer', data.name);
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -106,6 +109,9 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
metadata: { companyName: data.name }
|
metadata: { companyName: data.name }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { useUserRole } from '@/hooks/useUserRole';
|
|||||||
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
||||||
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import type { UploadedImage } from '@/types/company';
|
import type { UploadedImage } from '@/types/company';
|
||||||
|
|
||||||
// Zod output type (after transformation)
|
// Zod output type (after transformation)
|
||||||
@@ -73,7 +74,7 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(async (data) => {
|
<form onSubmit={handleSubmit(async (data) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error('You must be logged in to submit');
|
formToasts.error.generic('You must be logged in to submit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +94,11 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
|||||||
|
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
|
|
||||||
// Only show success toast and close if not editing through moderation queue
|
// Show success toast
|
||||||
if (!initialData?.id) {
|
if (initialData?.id) {
|
||||||
toast.success('Operator submitted for review');
|
formToasts.success.update('Operator', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Operator', data.name);
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -104,6 +107,9 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
|||||||
metadata: { companyName: data.name }
|
metadata: { companyName: data.name }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-
|
|||||||
import { SlugField } from '@/components/ui/slug-field';
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { MapPin, Save, X, Plus, AlertCircle } from 'lucide-react';
|
import { formToasts } from '@/lib/formToasts';
|
||||||
|
import { MapPin, Save, X, Plus, AlertCircle, Info } from 'lucide-react';
|
||||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
@@ -30,6 +31,10 @@ import { LocationSearch } from './LocationSearch';
|
|||||||
import { OperatorForm } from './OperatorForm';
|
import { OperatorForm } from './OperatorForm';
|
||||||
import { PropertyOwnerForm } from './PropertyOwnerForm';
|
import { PropertyOwnerForm } from './PropertyOwnerForm';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
||||||
|
import { TerminologyDialog } from '@/components/help/TerminologyDialog';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { fieldHints } from '@/lib/enhancedValidation';
|
||||||
|
|
||||||
const parkSchema = z.object({
|
const parkSchema = z.object({
|
||||||
name: z.string().min(1, 'Park name is required'),
|
name: z.string().min(1, 'Park name is required'),
|
||||||
@@ -38,9 +43,9 @@ const parkSchema = z.object({
|
|||||||
park_type: z.string().min(1, 'Park type is required'),
|
park_type: z.string().min(1, 'Park type is required'),
|
||||||
status: z.string().min(1, 'Status is required'),
|
status: z.string().min(1, 'Status is required'),
|
||||||
opening_date: z.string().optional().transform(val => val || undefined),
|
opening_date: z.string().optional().transform(val => val || undefined),
|
||||||
opening_date_precision: z.enum(['day', 'month', 'year']).optional(),
|
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
|
||||||
closing_date: z.string().optional().transform(val => val || undefined),
|
closing_date: z.string().optional().transform(val => val || undefined),
|
||||||
closing_date_precision: z.enum(['day', 'month', 'year']).optional(),
|
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional(),
|
||||||
location: z.object({
|
location: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
street_address: z.string().optional(),
|
street_address: z.string().optional(),
|
||||||
@@ -290,7 +295,16 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
await onSubmit(submissionData);
|
await onSubmit(submissionData);
|
||||||
|
|
||||||
// Parent component handles success feedback
|
// Show success toast
|
||||||
|
if (isModerator()) {
|
||||||
|
formToasts.success.moderatorApproval('Park', data.name);
|
||||||
|
} else if (isEditing) {
|
||||||
|
formToasts.success.update('Park', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Park', data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent component handles modal closing/navigation
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
handleError(error, {
|
handleError(error, {
|
||||||
@@ -304,6 +318,9 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(errorMessage);
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -314,12 +331,19 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-4xl mx-auto">
|
<Card className="w-full max-w-4xl mx-auto">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<MapPin className="w-5 h-5" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{isEditing ? 'Edit Park' : 'Create New Park'}
|
<MapPin className="w-5 h-5" />
|
||||||
</CardTitle>
|
{isEditing ? 'Edit Park' : 'Create New Park'}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<TerminologyDialog />
|
||||||
|
<SubmissionHelpDialog type="park" variant="icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<TooltipProvider>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -370,6 +394,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Choose the primary classification. Theme parks have themed areas, while amusement parks focus on rides.</p>
|
||||||
|
</div>
|
||||||
{errors.park_type && (
|
{errors.park_type && (
|
||||||
<p className="text-sm text-destructive">{errors.park_type.message}</p>
|
<p className="text-sm text-destructive">{errors.park_type.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -395,6 +423,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Current operational status. Use "Closed Temporarily" for seasonal closures or renovations.</p>
|
||||||
|
</div>
|
||||||
{errors.status && (
|
{errors.status && (
|
||||||
<p className="text-sm text-destructive">{errors.status.message}</p>
|
<p className="text-sm text-destructive">{errors.status.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -405,7 +437,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<FlexibleDateInput
|
<FlexibleDateInput
|
||||||
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
|
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
|
||||||
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('opening_date_precision') as DatePrecision) || 'exact'}
|
||||||
onChange={(date, precision) => {
|
onChange={(date, precision) => {
|
||||||
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||||
setValue('opening_date_precision', precision);
|
setValue('opening_date_precision', precision);
|
||||||
@@ -418,7 +450,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
<FlexibleDateInput
|
<FlexibleDateInput
|
||||||
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
||||||
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('closing_date_precision') as DatePrecision) || 'exact'}
|
||||||
onChange={(date, precision) => {
|
onChange={(date, precision) => {
|
||||||
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||||
setValue('closing_date_precision', precision);
|
setValue('closing_date_precision', precision);
|
||||||
@@ -446,6 +478,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
}}
|
}}
|
||||||
initialLocationId={watch('location_id')}
|
initialLocationId={watch('location_id')}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Search by park name, address, or city. Select from results to auto-fill coordinates and timezone.</p>
|
||||||
|
</div>
|
||||||
{errors.location && (
|
{errors.location && (
|
||||||
<p className="text-sm text-destructive flex items-center gap-1">
|
<p className="text-sm text-destructive flex items-center gap-1">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<AlertCircle className="w-4 h-4" />
|
||||||
@@ -462,6 +498,10 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{/* Operator & Property Owner Selection */}
|
{/* Operator & Property Owner Selection */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
|
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground mb-3">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>The operator runs the park, while the property owner owns the land. Often the same entity.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -590,6 +630,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('website_url')}
|
{...register('website_url')}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.websiteUrl}</p>
|
||||||
{errors.website_url && (
|
{errors.website_url && (
|
||||||
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -602,6 +643,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('phone')}
|
{...register('phone')}
|
||||||
placeholder="+1 (555) 123-4567"
|
placeholder="+1 (555) 123-4567"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -612,6 +654,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('email')}
|
{...register('email')}
|
||||||
placeholder="contact@park.com"
|
placeholder="contact@park.com"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.email}</p>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -643,7 +686,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
placeholder="https://example.com/article"
|
placeholder="https://example.com/article"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Where did you find this information? (e.g., official website, news article, press release)
|
{fieldHints.sourceUrl}
|
||||||
</p>
|
</p>
|
||||||
{errors.source_url && (
|
{errors.source_url && (
|
||||||
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
||||||
@@ -665,7 +708,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{watch('submission_notes')?.length || 0}/1000 characters
|
{fieldHints.submissionNotes} ({watch('submission_notes')?.length || 0}/1000 characters)
|
||||||
</p>
|
</p>
|
||||||
{errors.submission_notes && (
|
{errors.submission_notes && (
|
||||||
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
||||||
@@ -704,6 +747,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Operator Modal */}
|
{/* Operator Modal */}
|
||||||
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
|
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { useUserRole } from '@/hooks/useUserRole';
|
|||||||
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
||||||
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import type { UploadedImage } from '@/types/company';
|
import type { UploadedImage } from '@/types/company';
|
||||||
|
|
||||||
// Zod output type (after transformation)
|
// Zod output type (after transformation)
|
||||||
@@ -73,7 +74,7 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(async (data) => {
|
<form onSubmit={handleSubmit(async (data) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast.error('You must be logged in to submit');
|
formToasts.error.generic('You must be logged in to submit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +94,11 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
|||||||
|
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
|
|
||||||
// Only show success toast and close if not editing through moderation queue
|
// Show success toast
|
||||||
if (!initialData?.id) {
|
if (initialData?.id) {
|
||||||
toast.success('Property owner submitted for review');
|
formToasts.success.update('Property Owner', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Property Owner', data.name);
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -104,6 +107,9 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
|||||||
metadata: { companyName: data.name }
|
metadata: { companyName: data.name }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { SlugField } from '@/components/ui/slug-field';
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { Plus, Zap, Save, X, Building2, AlertCircle } from 'lucide-react';
|
import { formToasts } from '@/lib/formToasts';
|
||||||
|
import { Plus, Zap, Save, X, Building2, AlertCircle, Info, HelpCircle } from 'lucide-react';
|
||||||
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
|
||||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||||
import { useManufacturers, useRideModels, useParks } from '@/hooks/useAutocompleteData';
|
import { useManufacturers, useRideModels, useParks } from '@/hooks/useAutocompleteData';
|
||||||
@@ -34,6 +36,10 @@ import { ParkForm } from './ParkForm';
|
|||||||
import { TechnicalSpecsEditor, validateTechnicalSpecs } from './editors/TechnicalSpecsEditor';
|
import { TechnicalSpecsEditor, validateTechnicalSpecs } from './editors/TechnicalSpecsEditor';
|
||||||
import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
|
import { CoasterStatsEditor, validateCoasterStats } from './editors/CoasterStatsEditor';
|
||||||
import { FormerNamesEditor } from './editors/FormerNamesEditor';
|
import { FormerNamesEditor } from './editors/FormerNamesEditor';
|
||||||
|
import { SubmissionHelpDialog } from '@/components/help/SubmissionHelpDialog';
|
||||||
|
import { TerminologyDialog } from '@/components/help/TerminologyDialog';
|
||||||
|
import { TermTooltip } from '@/components/ui/term-tooltip';
|
||||||
|
import { fieldHints } from '@/lib/enhancedValidation';
|
||||||
import {
|
import {
|
||||||
convertValueToMetric,
|
convertValueToMetric,
|
||||||
convertValueFromMetric,
|
convertValueFromMetric,
|
||||||
@@ -227,9 +233,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
ride_sub_type: initialData?.ride_sub_type || '',
|
ride_sub_type: initialData?.ride_sub_type || '',
|
||||||
status: initialData?.status || 'operating' as const, // Store DB value directly
|
status: initialData?.status || 'operating' as const, // Store DB value directly
|
||||||
opening_date: initialData?.opening_date || undefined,
|
opening_date: initialData?.opening_date || undefined,
|
||||||
opening_date_precision: initialData?.opening_date_precision || 'day',
|
opening_date_precision: initialData?.opening_date_precision || 'exact',
|
||||||
closing_date: initialData?.closing_date || undefined,
|
closing_date: initialData?.closing_date || undefined,
|
||||||
closing_date_precision: initialData?.closing_date_precision || 'day',
|
closing_date_precision: initialData?.closing_date_precision || 'exact',
|
||||||
// Convert metric values to user's preferred unit for display
|
// Convert metric values to user's preferred unit for display
|
||||||
height_requirement: initialData?.height_requirement
|
height_requirement: initialData?.height_requirement
|
||||||
? convertValueFromMetric(initialData.height_requirement, getDisplayUnit('cm', measurementSystem), 'cm')
|
? convertValueFromMetric(initialData.height_requirement, getDisplayUnit('cm', measurementSystem), 'cm')
|
||||||
@@ -355,14 +361,14 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
// Pass clean data to parent with extended fields
|
// Pass clean data to parent with extended fields
|
||||||
await onSubmit(metricData);
|
await onSubmit(metricData);
|
||||||
|
|
||||||
toast({
|
// Show success toast
|
||||||
title: isEditing ? "Ride Updated" : "Submission Sent",
|
if (isModerator()) {
|
||||||
description: isEditing
|
formToasts.success.moderatorApproval('Ride', data.name);
|
||||||
? "The ride information has been updated successfully."
|
} else if (isEditing) {
|
||||||
: tempNewManufacturer
|
formToasts.success.update('Ride', data.name);
|
||||||
? "Ride, manufacturer, and model submitted for review"
|
} else {
|
||||||
: "Ride submitted for review"
|
formToasts.success.create('Ride', data.name);
|
||||||
});
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleError(error, {
|
handleError(error, {
|
||||||
action: isEditing ? 'Update Ride' : 'Create Ride',
|
action: isEditing ? 'Update Ride' : 'Create Ride',
|
||||||
@@ -373,6 +379,9 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -381,15 +390,22 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-4xl mx-auto">
|
<TooltipProvider>
|
||||||
<CardHeader>
|
<Card className="w-full max-w-4xl mx-auto">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardHeader>
|
||||||
<Zap className="w-5 h-5" />
|
<div className="flex items-center justify-between">
|
||||||
{isEditing ? 'Edit Ride' : 'Create New Ride'}
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardTitle>
|
<Zap className="w-5 h-5" />
|
||||||
</CardHeader>
|
{isEditing ? 'Edit Ride' : 'Create New Ride'}
|
||||||
<CardContent>
|
</CardTitle>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
<div className="flex gap-2">
|
||||||
|
<TerminologyDialog />
|
||||||
|
<SubmissionHelpDialog type="ride" variant="icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -529,6 +545,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Primary ride type. Choose roller coaster for any coaster, flat ride for spinners/swings, water ride for flumes/rapids.</p>
|
||||||
|
</div>
|
||||||
{errors.category && (
|
{errors.category && (
|
||||||
<p className="text-sm text-destructive">{errors.category.message}</p>
|
<p className="text-sm text-destructive">{errors.category.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -541,6 +561,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('ride_sub_type')}
|
{...register('ride_sub_type')}
|
||||||
placeholder="e.g. Inverted Coaster, Log Flume"
|
placeholder="e.g. Inverted Coaster, Log Flume"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Specific type within category (e.g., "Inverted Coaster", "Flume").</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -563,6 +587,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Current state. Use "Relocated" if moved to another park.</p>
|
||||||
|
</div>
|
||||||
{errors.status && (
|
{errors.status && (
|
||||||
<p className="text-sm text-destructive">{errors.status.message}</p>
|
<p className="text-sm text-destructive">{errors.status.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -572,6 +600,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{/* Manufacturer & Model Selection */}
|
{/* Manufacturer & Model Selection */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Manufacturer & Model</h3>
|
<h3 className="text-lg font-semibold">Manufacturer & Model</h3>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground mb-3">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>The company that built the ride. Model is the specific product line (e.g., "B&M" makes "Inverted Coaster" models).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Manufacturer Column */}
|
{/* Manufacturer Column */}
|
||||||
@@ -711,7 +743,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<FlexibleDateInput
|
<FlexibleDateInput
|
||||||
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
|
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
|
||||||
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('opening_date_precision') as DatePrecision) || 'exact'}
|
||||||
onChange={(date, precision) => {
|
onChange={(date, precision) => {
|
||||||
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||||
setValue('opening_date_precision', precision);
|
setValue('opening_date_precision', precision);
|
||||||
@@ -724,7 +756,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
|
|
||||||
<FlexibleDateInput
|
<FlexibleDateInput
|
||||||
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
|
||||||
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('closing_date_precision') as DatePrecision) || 'exact'}
|
||||||
onChange={(date, precision) => {
|
onChange={(date, precision) => {
|
||||||
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
|
||||||
setValue('closing_date_precision', precision);
|
setValue('closing_date_precision', precision);
|
||||||
@@ -747,6 +779,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('height_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('height_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder={measurementSystem === 'imperial' ? 'e.g. 47' : 'e.g. 120'}
|
placeholder={measurementSystem === 'imperial' ? 'e.g. 47' : 'e.g. 120'}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.heightRequirement}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -758,6 +791,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('age_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('age_requirement', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 8"
|
placeholder="e.g. 8"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Minimum age in years, if different from height requirement.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -765,6 +802,10 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{selectedCategory === 'roller_coaster' && (
|
{selectedCategory === 'roller_coaster' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Roller Coaster Details</h3>
|
<h3 className="text-lg font-semibold">Roller Coaster Details</h3>
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground mb-3">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>Specific attributes for roller coasters. Track/support materials help classify hybrid coasters.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -816,8 +857,16 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Track Material(s)</Label>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">Select all materials used in the track</p>
|
<Label>
|
||||||
|
<TermTooltip term="ibox-track" showIcon={false}>
|
||||||
|
Track Material(s)
|
||||||
|
</TermTooltip>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Common: <TermTooltip term="ibox-track" inline>Steel</TermTooltip>, Wood, <TermTooltip term="hybrid-coaster" inline>Hybrid (RMC IBox)</TermTooltip>
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{TRACK_MATERIALS.map((material) => (
|
{TRACK_MATERIALS.map((material) => (
|
||||||
<div key={material.value} className="flex items-center space-x-2">
|
<div key={material.value} className="flex items-center space-x-2">
|
||||||
@@ -842,8 +891,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Support Material(s)</Label>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">Select all materials used in the supports</p>
|
<Label>Support Material(s)</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Materials used for support structure (can differ from track)
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{SUPPORT_MATERIALS.map((material) => (
|
{SUPPORT_MATERIALS.map((material) => (
|
||||||
<div key={material.value} className="flex items-center space-x-2">
|
<div key={material.value} className="flex items-center space-x-2">
|
||||||
@@ -868,8 +921,16 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Propulsion Method(s)</Label>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">Select all propulsion methods used</p>
|
<Label>
|
||||||
|
<TermTooltip term="lsm" showIcon={false}>
|
||||||
|
Propulsion Method(s)
|
||||||
|
</TermTooltip>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Common: <TermTooltip term="lsm" inline>LSM Launch</TermTooltip>, <TermTooltip term="chain-lift" inline>Chain Lift</TermTooltip>, <TermTooltip term="hydraulic-launch" inline>Hydraulic Launch</TermTooltip>
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{PROPULSION_METHODS.map((method) => (
|
{PROPULSION_METHODS.map((method) => (
|
||||||
<div key={method.value} className="flex items-center space-x-2">
|
<div key={method.value} className="flex items-center space-x-2">
|
||||||
@@ -1310,6 +1371,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('capacity_per_hour', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('capacity_per_hour', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 1200"
|
placeholder="e.g. 1200"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.capacity}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1321,6 +1383,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('duration_seconds', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('duration_seconds', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 180"
|
placeholder="e.g. 180"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.duration}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1333,6 +1396,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('max_speed_kmh', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('max_speed_kmh', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder={measurementSystem === 'imperial' ? 'e.g. 50' : 'e.g. 80.5'}
|
placeholder={measurementSystem === 'imperial' ? 'e.g. 50' : 'e.g. 80.5'}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.speed}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1368,6 +1432,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
{...register('inversions', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
{...register('inversions', { setValueAs: (v) => v === "" ? undefined : parseFloat(v) })}
|
||||||
placeholder="e.g. 7"
|
placeholder="e.g. 7"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{fieldHints.inversions}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1421,7 +1486,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
placeholder="https://example.com/article"
|
placeholder="https://example.com/article"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Where did you find this information? (e.g., official website, news article, press release)
|
{fieldHints.sourceUrl}
|
||||||
</p>
|
</p>
|
||||||
{errors.source_url && (
|
{errors.source_url && (
|
||||||
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
<p className="text-sm text-destructive">{errors.source_url.message}</p>
|
||||||
@@ -1443,7 +1508,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{watch('submission_notes')?.length || 0}/1000 characters
|
{fieldHints.submissionNotes} ({watch('submission_notes')?.length || 0}/1000 characters)
|
||||||
</p>
|
</p>
|
||||||
{errors.submission_notes && (
|
{errors.submission_notes && (
|
||||||
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
|
||||||
@@ -1574,5 +1639,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import type { RideModelTechnicalSpec } from '@/types/database';
|
import type { RideModelTechnicalSpec } from '@/types/database';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { toast } from 'sonner';
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { formToasts } from '@/lib/formToasts';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -112,12 +113,21 @@ export function RideModelForm({
|
|||||||
manufacturer_id: manufacturerId,
|
manufacturer_id: manufacturerId,
|
||||||
_technical_specifications: technicalSpecs
|
_technical_specifications: technicalSpecs
|
||||||
});
|
});
|
||||||
toast.success('Ride model submitted for review');
|
|
||||||
|
// Show success toast
|
||||||
|
if (initialData?.id) {
|
||||||
|
formToasts.success.update('Ride Model', data.name);
|
||||||
|
} else {
|
||||||
|
formToasts.success.create('Ride Model', data.name);
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleError(error, {
|
handleError(error, {
|
||||||
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
|
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
formToasts.error.generic(getErrorMessage(error));
|
||||||
|
|
||||||
// Re-throw so parent can handle modal closing
|
// Re-throw so parent can handle modal closing
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2, HelpCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { fieldHints } from "@/lib/enhancedValidation";
|
||||||
import {
|
import {
|
||||||
convertValueToMetric,
|
convertValueToMetric,
|
||||||
convertValueFromMetric,
|
convertValueFromMetric,
|
||||||
@@ -126,14 +128,25 @@ export function TechnicalSpecsEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<Label>Technical Specifications</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addSpec}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Label>Technical Specifications</Label>
|
||||||
Add Specification
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
</div>
|
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p>Add custom specifications like track material (Steel, Wood), propulsion method (LSM Launch, Chain Lift), train type, etc. Use metric units only.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addSpec}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Specification
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{specs.length === 0 ? (
|
{specs.length === 0 ? (
|
||||||
<Card className="p-6 text-center text-muted-foreground">
|
<Card className="p-6 text-center text-muted-foreground">
|
||||||
@@ -145,7 +158,24 @@ export function TechnicalSpecsEditor({
|
|||||||
<Card key={index} className="p-4">
|
<Card key={index} className="p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Label className="text-xs">Specification Name</Label>
|
<div className="flex items-center gap-1">
|
||||||
|
<Label className="text-xs">Specification Name</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="font-semibold mb-1">Examples:</p>
|
||||||
|
<ul className="text-xs space-y-1">
|
||||||
|
<li>• Track Material (Steel/Wood)</li>
|
||||||
|
<li>• Propulsion Method (LSM Launch, Chain Lift)</li>
|
||||||
|
<li>• Train Type (Sit-down, Inverted)</li>
|
||||||
|
<li>• Restraint System (Lap bar, OTSR)</li>
|
||||||
|
<li>• Launch Speed (km/h)</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={spec.spec_name}
|
value={spec.spec_name}
|
||||||
onChange={(e) => updateSpec(index, 'spec_name', e.target.value)}
|
onChange={(e) => updateSpec(index, 'spec_name', e.target.value)}
|
||||||
@@ -189,7 +219,22 @@ export function TechnicalSpecsEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Type</Label>
|
<div className="flex items-center gap-1">
|
||||||
|
<Label className="text-xs">Type</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<ul className="text-xs space-y-1">
|
||||||
|
<li>• <strong>Text:</strong> Material names, methods (e.g., "Steel", "LSM Launch")</li>
|
||||||
|
<li>• <strong>Number:</strong> Measurements with units (e.g., speed, length)</li>
|
||||||
|
<li>• <strong>Yes/No:</strong> Features (e.g., "Has VR")</li>
|
||||||
|
<li>• <strong>Date:</strong> Installation dates</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={spec.spec_type}
|
value={spec.spec_type}
|
||||||
onValueChange={(value) => updateSpec(index, 'spec_type', value)}
|
onValueChange={(value) => updateSpec(index, 'spec_type', value)}
|
||||||
@@ -225,7 +270,23 @@ export function TechnicalSpecsEditor({
|
|||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-xs">Unit</Label>
|
<div className="flex items-center gap-1">
|
||||||
|
<Label className="text-xs">Unit</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="font-semibold mb-1">Metric units only:</p>
|
||||||
|
<ul className="text-xs space-y-1">
|
||||||
|
<li>• Speed: km/h (not mph)</li>
|
||||||
|
<li>• Distance: m, km, cm (not ft, mi, in)</li>
|
||||||
|
<li>• Weight: kg, g (not lb, oz)</li>
|
||||||
|
<li>• Leave empty for text values</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={spec.unit || ''}
|
value={spec.unit || ''}
|
||||||
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
|
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
|
||||||
@@ -257,7 +318,8 @@ export function TechnicalSpecsEditor({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class AnalyticsErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AnalyticsWrapper() {
|
export function AnalyticsWrapper() {
|
||||||
|
// Disable analytics in development to reduce console noise
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnalyticsErrorBoundary>
|
<AnalyticsErrorBoundary>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
|||||||
278
src/components/examples/FormFieldWrapperDemo.tsx
Normal file
278
src/components/examples/FormFieldWrapperDemo.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* FormFieldWrapper Live Demo
|
||||||
|
*
|
||||||
|
* This component demonstrates the FormFieldWrapper in action
|
||||||
|
* You can view this by navigating to /examples/form-field-wrapper
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormFieldWrapper, formFieldPresets } from '@/components/ui/form-field-wrapper';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
export function FormFieldWrapperDemo() {
|
||||||
|
const { register, formState: { errors }, watch, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => {
|
||||||
|
console.log('Form submitted:', data);
|
||||||
|
alert('Check console for form data');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="container mx-auto py-8 max-w-4xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>FormFieldWrapper Demo</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Interactive demonstration of the unified form field component
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="basic">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||||
|
<TabsTrigger value="terminology">Terminology</TabsTrigger>
|
||||||
|
<TabsTrigger value="presets">Presets</TabsTrigger>
|
||||||
|
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 mt-6">
|
||||||
|
{/* Basic Examples */}
|
||||||
|
<TabsContent value="basic" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Basic Field Types</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
These fields automatically show appropriate hints and validation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="website_url"
|
||||||
|
label="Website URL"
|
||||||
|
fieldType="url"
|
||||||
|
error={errors.website_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('website_url'),
|
||||||
|
placeholder: "https://example.com"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="email"
|
||||||
|
label="Email Address"
|
||||||
|
fieldType="email"
|
||||||
|
required
|
||||||
|
error={errors.email?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('email', { required: 'Email is required' }),
|
||||||
|
placeholder: "contact@example.com"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="phone"
|
||||||
|
label="Phone Number"
|
||||||
|
fieldType="phone"
|
||||||
|
error={errors.phone?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('phone'),
|
||||||
|
placeholder: "+1 (555) 123-4567"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Terminology Examples */}
|
||||||
|
<TabsContent value="terminology" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Fields with Terminology</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Hover over labels with icons to see terminology definitions
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="inversions"
|
||||||
|
label="Inversions"
|
||||||
|
fieldType="inversions"
|
||||||
|
termKey="inversion"
|
||||||
|
error={errors.inversions?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('inversions'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
placeholder: "e.g. 7"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="max_speed"
|
||||||
|
label="Max Speed (km/h)"
|
||||||
|
fieldType="speed"
|
||||||
|
termKey="kilometers-per-hour"
|
||||||
|
error={errors.max_speed?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('max_speed'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
step: 0.1,
|
||||||
|
placeholder: "e.g. 193"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="max_height"
|
||||||
|
label="Max Height (meters)"
|
||||||
|
fieldType="height"
|
||||||
|
termKey="meters"
|
||||||
|
error={errors.max_height?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('max_height'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
step: 0.1,
|
||||||
|
placeholder: "e.g. 94"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Preset Examples */}
|
||||||
|
<TabsContent value="presets" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Using Presets</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Common field configurations with one-line setup
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.sourceUrl({})}
|
||||||
|
id="source_url"
|
||||||
|
error={errors.source_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('source_url'),
|
||||||
|
placeholder: "https://source.com/article"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.heightRequirement({})}
|
||||||
|
id="height_requirement"
|
||||||
|
error={errors.height_requirement?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('height_requirement'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
placeholder: "122"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.capacity({})}
|
||||||
|
id="capacity"
|
||||||
|
error={errors.capacity?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('capacity'),
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
placeholder: "1200"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Advanced Examples */}
|
||||||
|
<TabsContent value="advanced" className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Advanced Features</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Textareas, character counting, and custom hints
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.submissionNotes({})}
|
||||||
|
id="submission_notes"
|
||||||
|
value={watch('submission_notes')}
|
||||||
|
error={errors.submission_notes?.message as string}
|
||||||
|
textareaProps={{
|
||||||
|
...register('submission_notes', {
|
||||||
|
maxLength: { value: 1000, message: 'Maximum 1000 characters' }
|
||||||
|
}),
|
||||||
|
placeholder: "Add context for moderators...",
|
||||||
|
rows: 4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="custom_field"
|
||||||
|
label="Custom Field with Override"
|
||||||
|
fieldType="text"
|
||||||
|
hint="This is a custom hint that overrides any automatic hint"
|
||||||
|
error={errors.custom_field?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('custom_field'),
|
||||||
|
placeholder: "Enter custom value"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="no_hint_field"
|
||||||
|
label="Field Without Hint"
|
||||||
|
fieldType="url"
|
||||||
|
hideHint
|
||||||
|
error={errors.no_hint_field?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('no_hint_field'),
|
||||||
|
placeholder: "https://"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Submit Form (Check Console)
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Benefits Card */}
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Benefits</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Consistency:</strong> All fields follow the same structure and styling</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Less Code:</strong> ~50% reduction in form field boilerplate</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Smart Defaults:</strong> Automatic hints based on field type</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Built-in Terminology:</strong> Hover tooltips for technical terms</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Easy Updates:</strong> Change hints in one place, updates everywhere</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span><strong>Type Safety:</strong> TypeScript ensures correct usage</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,11 +102,11 @@ export function TimeZoneIndependentDateRangePicker({
|
|||||||
if (!fromDate && !toDate) return null;
|
if (!fromDate && !toDate) return null;
|
||||||
|
|
||||||
if (fromDate && toDate) {
|
if (fromDate && toDate) {
|
||||||
return `${formatDateDisplay(fromDate, 'day')} - ${formatDateDisplay(toDate, 'day')}`;
|
return `${formatDateDisplay(fromDate, 'exact')} - ${formatDateDisplay(toDate, 'exact')}`;
|
||||||
} else if (fromDate) {
|
} else if (fromDate) {
|
||||||
return `From ${formatDateDisplay(fromDate, 'day')}`;
|
return `From ${formatDateDisplay(fromDate, 'exact')}`;
|
||||||
} else if (toDate) {
|
} else if (toDate) {
|
||||||
return `Until ${formatDateDisplay(toDate, 'day')}`;
|
return `Until ${formatDateDisplay(toDate, 'exact')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
385
src/components/help/SubmissionHelpDialog.tsx
Normal file
385
src/components/help/SubmissionHelpDialog.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface SubmissionHelpDialogProps {
|
||||||
|
type: 'park' | 'ride';
|
||||||
|
variant?: 'button' | 'icon';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionHelpDialog({ type, variant = 'button' }: SubmissionHelpDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{variant === 'button' ? (
|
||||||
|
<Button type="button" variant="outline" size="sm">
|
||||||
|
<HelpCircle className="h-4 w-4 mr-2" />
|
||||||
|
Submission Guide
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="ghost" size="icon">
|
||||||
|
<HelpCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{type === 'park' ? 'Park' : 'Ride'} Submission Guide
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Everything you need to know about submitting {type === 'park' ? 'parks' : 'rides'} to ThrillWiki
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[60vh] pr-4">
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
{/* Date Precision */}
|
||||||
|
<AccordionItem value="date-precision">
|
||||||
|
<AccordionTrigger>Date Precision Options</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose how precise your date information is. This helps maintain accuracy when exact dates aren't known.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Exact Day</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Use when you know the specific date (e.g., June 15, 2010)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: Opening day announcement</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Month & Year</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Use when you only know the month (e.g., June 2010)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: "Opened in summer 2010"</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Year Only</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Use when you only know the year (e.g., 2010)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: Historical records show "1985"</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Decade</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Use for events in a general decade (e.g., 1980s)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: "Built in the early 1970s"</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Century</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Use for very old dates spanning a century</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: "19th century fairground"</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Approximate</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Use when the date is uncertain or estimated</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: "circa 2005"</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{type === 'park' && (
|
||||||
|
<>
|
||||||
|
{/* Park Types */}
|
||||||
|
<AccordionItem value="park-types">
|
||||||
|
<AccordionTrigger>Park Types Explained</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Theme Park</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Has distinct themed areas with immersive experiences and storytelling</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Disneyland, Universal Studios</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Amusement Park</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Focuses on rides and attractions without heavy theming</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Cedar Point, Six Flags</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Water Park</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Water-based attractions like slides, wave pools, lazy rivers</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Schlitterbahn, Aquatica</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Family Entertainment Center</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Indoor facilities with arcade games, mini golf, go-karts</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Chuck E. Cheese, Dave & Buster's</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Operator vs Owner */}
|
||||||
|
<AccordionItem value="operator-owner">
|
||||||
|
<AccordionTrigger>Operator vs. Property Owner</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-green-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm">Operator</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The company that runs day-to-day operations, manages staff, and operates the park
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: Six Flags operates many parks</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-blue-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm">Property Owner</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The entity that owns the land and physical property
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: Real estate investment company</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted p-3 rounded-md mt-3">
|
||||||
|
<p className="font-semibold text-sm mb-1">💡 Pro Tip</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Often the operator and owner are the same company (check the "Operator is also the property owner" box).
|
||||||
|
But sometimes they're different - for example, a park might lease land from a property owner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'ride' && (
|
||||||
|
<>
|
||||||
|
{/* Ride Categories */}
|
||||||
|
<AccordionItem value="ride-categories">
|
||||||
|
<AccordionTrigger>Ride Categories</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Roller Coaster</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Any type of coaster with a track and gravity-based movement</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Includes: Steel, Wood, Inverted, Flying</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Flat Ride</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Spinning, swinging, or rotating rides at ground level</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Tilt-A-Whirl, Scrambler, Top Spin</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Water Ride</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Rides involving water, splashing, or getting wet</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Log Flume, River Rapids, Shoot-the-Chute</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Dark Ride</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Indoor rides with controlled lighting and theming</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Haunted Mansion, Pirates of the Caribbean</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Manufacturer vs Designer */}
|
||||||
|
<AccordionItem value="manufacturer-designer">
|
||||||
|
<AccordionTrigger>Manufacturer vs. Designer</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-green-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm">Manufacturer</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The company that physically built and engineered the ride
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Intamin, B&M, Vekoma, RMC</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-blue-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm">Designer (Optional)</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The design firm or consultant that created the ride concept and layout
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Examples: Werner Stengel, Ride Centerline</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted p-3 rounded-md mt-3">
|
||||||
|
<p className="font-semibold text-sm mb-1">💡 Pro Tip</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Most rides only need a manufacturer. Add a designer only if they're notably different
|
||||||
|
(e.g., Werner Stengel designed layouts for many B&M coasters).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Technical Specs */}
|
||||||
|
<AccordionItem value="technical-specs">
|
||||||
|
<AccordionTrigger>Technical Specifications</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add custom specifications beyond the standard fields. Use for unique features.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Common Spec Examples</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||||
|
<li>• Track Material: "Steel" or "Wood"</li>
|
||||||
|
<li>• Propulsion Method: "LSM Launch", "Chain Lift"</li>
|
||||||
|
<li>• Train Type: "Sit-down", "Inverted", "Flying"</li>
|
||||||
|
<li>• Restraint System: "Lap bar", "Over-shoulder"</li>
|
||||||
|
<li>• Number of Trains: "3"</li>
|
||||||
|
<li>• Riders per Train: "28"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-destructive/10 border border-destructive/20 p-3 rounded-md">
|
||||||
|
<p className="font-semibold text-sm mb-1 text-destructive">⚠️ Important: Metric Units Only</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
All measurements must use metric units (km/h, m, cm, kg). The system will convert
|
||||||
|
them to your preferred units for display. Examples: "km/h" not "mph", "m" not "ft"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Units and Measurements */}
|
||||||
|
<AccordionItem value="units">
|
||||||
|
<AccordionTrigger>Units and Measurements</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
ThrillWiki stores all measurements in metric units but displays them in your preferred system.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-muted p-3 rounded-md">
|
||||||
|
<p className="font-semibold text-sm mb-2">How It Works</p>
|
||||||
|
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside">
|
||||||
|
<li>Enter values in YOUR preferred units (metric or imperial)</li>
|
||||||
|
<li>System automatically converts to metric for storage</li>
|
||||||
|
<li>Data displays in each user's preferred unit system</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Speed</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Enter in km/h or mph (auto-converts)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: 120 km/h = 74.6 mph</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Height / Length</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Enter in meters or feet (auto-converts)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: 50m = 164ft</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-primary pl-3">
|
||||||
|
<p className="font-semibold text-sm">Height Requirement</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Enter in cm or inches (auto-converts)</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-1">Example: 120cm = 47in</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Submission Process */}
|
||||||
|
<AccordionItem value="submission-process">
|
||||||
|
<AccordionTrigger>Submission Process</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-muted p-3 rounded-md">
|
||||||
|
<p className="font-semibold text-sm mb-2">How Submissions Work</p>
|
||||||
|
<ol className="text-xs text-muted-foreground space-y-2 list-decimal list-inside">
|
||||||
|
<li>Fill out the form with accurate information</li>
|
||||||
|
<li>Your submission goes to a moderation queue</li>
|
||||||
|
<li>Moderators review for accuracy and completeness</li>
|
||||||
|
<li>Approved submissions become visible on the site</li>
|
||||||
|
<li>All changes are versioned - edit history is preserved</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-green-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm text-green-600">✓ Required Fields</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Fields marked with * are required. You cannot submit without completing these.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-blue-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm text-blue-600">Source URL & Notes</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Always provide sources for your information. This helps moderators verify accuracy
|
||||||
|
and gives credit to original sources. Include official websites, press releases, or news articles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Best Practices */}
|
||||||
|
<AccordionItem value="best-practices">
|
||||||
|
<AccordionTrigger>Best Practices</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-l-2 border-green-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm">✓ Do</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1 mt-1 list-disc list-inside">
|
||||||
|
<li>Use official names from park/manufacturer sources</li>
|
||||||
|
<li>Provide accurate dates with appropriate precision</li>
|
||||||
|
<li>Include source URLs for verification</li>
|
||||||
|
<li>Add detailed descriptions that help users</li>
|
||||||
|
<li>Use proper capitalization and spelling</li>
|
||||||
|
<li>Check if the {type} already exists before creating</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-red-500 pl-3">
|
||||||
|
<p className="font-semibold text-sm text-destructive">✗ Don't</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1 mt-1 list-disc list-inside">
|
||||||
|
<li>Use nicknames or unofficial names</li>
|
||||||
|
<li>Guess dates - use appropriate precision instead</li>
|
||||||
|
<li>Submit without sources or verification</li>
|
||||||
|
<li>Leave descriptions empty or vague</li>
|
||||||
|
<li>Use all caps or poor formatting</li>
|
||||||
|
<li>Create duplicates of existing entries</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 rounded-md">
|
||||||
|
<p className="font-semibold text-sm mb-1 text-blue-700 dark:text-blue-300">💡 Quality over Speed</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Take your time to ensure accuracy. Well-documented submissions are approved faster
|
||||||
|
and help build a reliable database for everyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/help/TerminologyDialog.tsx
Normal file
135
src/components/help/TerminologyDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { BookOpen, Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getAllCategories, getTermsByCategory, searchGlossary, type GlossaryTerm } from "@/lib/glossary";
|
||||||
|
|
||||||
|
export function TerminologyDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const categories = getAllCategories();
|
||||||
|
const searchResults = searchQuery ? searchGlossary(searchQuery) : [];
|
||||||
|
|
||||||
|
const renderTermCard = (term: GlossaryTerm) => (
|
||||||
|
<div key={term.term} className="p-4 border rounded-lg space-y-2 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="font-semibold">{term.term}</h4>
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
{term.category.replace('-', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{term.definition}</p>
|
||||||
|
{term.example && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
<span className="font-medium">Example:</span> {term.example}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{term.relatedTerms && term.relatedTerms.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Related:</span>
|
||||||
|
{term.relatedTerms.map(rt => (
|
||||||
|
<Badge key={rt} variant="outline" className="text-xs">
|
||||||
|
{rt.replace(/-/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="w-4 h-4 mr-2" />
|
||||||
|
Terminology
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Theme Park Terminology Reference</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Quick reference for technical terms, manufacturers, and ride types
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search terminology..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchQuery ? (
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
searchResults.map(renderTermCard)
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No terms found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="manufacturer" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-7">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<TabsTrigger key={cat} value={cat} className="text-xs">
|
||||||
|
{cat === 'manufacturer' ? 'Mfg.' :
|
||||||
|
cat === 'technology' ? 'Tech' :
|
||||||
|
cat === 'measurement' ? 'Units' :
|
||||||
|
cat.charAt(0).toUpperCase() + cat.slice(1).substring(0, 4)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{categories.map(cat => {
|
||||||
|
const terms = getTermsByCategory(cat);
|
||||||
|
return (
|
||||||
|
<TabsContent key={cat} value={cat}>
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-3 pr-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
|
<h3 className="font-semibold capitalize">
|
||||||
|
{cat.replace('-', ' ')}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{terms.length} terms</Badge>
|
||||||
|
</div>
|
||||||
|
{terms.map(renderTermCard)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-4 border-t text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="text-xs">Tip</Badge>
|
||||||
|
<span>Hover over underlined terms in forms to see quick definitions</span>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart } from 'lucide-react';
|
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart, Database } from 'lucide-react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { useSidebar } from '@/hooks/useSidebar';
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
@@ -73,6 +73,12 @@ export function AdminSidebar() {
|
|||||||
url: '/admin/database-stats',
|
url: '/admin/database-stats',
|
||||||
icon: BarChart,
|
icon: BarChart,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Database Maintenance',
|
||||||
|
url: '/admin/database-maintenance',
|
||||||
|
icon: Database,
|
||||||
|
visible: isSuperuser, // Only superusers can access
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Users',
|
title: 'Users',
|
||||||
url: '/admin/users',
|
url: '/admin/users',
|
||||||
@@ -134,7 +140,7 @@ export function AdminSidebar() {
|
|||||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navItems.map((item) => (
|
{navItems.filter(item => item.visible !== false).map((item) => (
|
||||||
<SidebarMenuItem key={item.url}>
|
<SidebarMenuItem key={item.url}>
|
||||||
<SidebarMenuButton asChild tooltip={collapsed ? item.title : undefined}>
|
<SidebarMenuButton asChild tooltip={collapsed ? item.title : undefined}>
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
34
src/components/layout/PageTransition.tsx
Normal file
34
src/components/layout/PageTransition.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface PageTransitionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTransition({ children }: PageTransitionProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const [displayLocation, setDisplayLocation] = useState(location);
|
||||||
|
const [transitionStage, setTransitionStage] = useState<'fade-in' | 'fade-out'>('fade-in');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location !== displayLocation) {
|
||||||
|
setTransitionStage('fade-out');
|
||||||
|
}
|
||||||
|
}, [location, displayLocation]);
|
||||||
|
|
||||||
|
const onAnimationEnd = () => {
|
||||||
|
if (transitionStage === 'fade-out') {
|
||||||
|
setTransitionStage('fade-in');
|
||||||
|
setDisplayLocation(location);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${transitionStage === 'fade-out' ? 'animate-fade-out' : 'animate-fade-in'}`}
|
||||||
|
onAnimationEnd={onAnimationEnd}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/loading/CompanyDetailSkeleton.tsx
Normal file
98
src/components/loading/CompanyDetailSkeleton.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export function CompanyDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="h-4 bg-muted rounded w-56 mb-4" />
|
||||||
|
|
||||||
|
{/* Edit Button Area */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="h-10 bg-muted rounded w-32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Banner */}
|
||||||
|
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12 max-w-6xl mx-auto">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="h-8 bg-muted rounded w-16 mx-auto mb-2" />
|
||||||
|
<div className="h-3 bg-muted rounded w-20 mx-auto" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b mb-6">
|
||||||
|
{['Overview', 'Rides', 'Models', 'Photos'].map((tab) => (
|
||||||
|
<div key={tab} className="h-10 bg-muted rounded w-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Grid */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Description Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 bg-muted rounded w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-4/5" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Products Grid */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 bg-muted rounded w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="aspect-square bg-muted rounded-lg" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-3 bg-muted rounded w-2/3" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Company Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 bg-muted rounded w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="w-32 h-32 bg-muted rounded mx-auto mb-4" />
|
||||||
|
|
||||||
|
{/* Info Items */}
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-4 h-4 bg-muted rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-muted rounded w-24 mb-1" />
|
||||||
|
<div className="h-3 bg-muted rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/loading/ParkDetailSkeleton.tsx
Normal file
101
src/components/loading/ParkDetailSkeleton.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export function ParkDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="h-4 bg-muted rounded w-48 mb-4" />
|
||||||
|
|
||||||
|
{/* Edit Button Area */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="h-10 bg-muted rounded w-32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Banner */}
|
||||||
|
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12 max-w-6xl mx-auto">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="h-8 bg-muted rounded w-16 mx-auto mb-2" />
|
||||||
|
<div className="h-3 bg-muted rounded w-20 mx-auto" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b mb-6">
|
||||||
|
{['Overview', 'Rides', 'Reviews', 'Photos', 'History'].map((tab) => (
|
||||||
|
<div key={tab} className="h-10 bg-muted rounded w-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Grid */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Description Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 bg-muted rounded w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Featured Rides Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 bg-muted rounded w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="aspect-square bg-muted rounded-lg" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-3 bg-muted rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 bg-muted rounded w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-4 h-4 bg-muted rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-muted rounded w-24 mb-1" />
|
||||||
|
<div className="h-3 bg-muted rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Map Card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="aspect-square bg-muted rounded-lg" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/loading/RideDetailSkeleton.tsx
Normal file
106
src/components/loading/RideDetailSkeleton.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export function RideDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-7xl animate-pulse">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="h-4 bg-muted rounded w-64 mb-4" />
|
||||||
|
|
||||||
|
{/* Edit Button Area */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="h-10 bg-muted rounded w-32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Banner */}
|
||||||
|
<div className="aspect-[21/9] bg-muted rounded-lg mb-8" />
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-12">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Card key={i} className="border-0 bg-gradient-to-br from-muted/50 to-muted/30">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="w-6 h-6 bg-muted rounded mx-auto mb-2" />
|
||||||
|
<div className="h-8 bg-muted rounded w-16 mx-auto mb-1" />
|
||||||
|
<div className="h-3 bg-muted rounded w-12 mx-auto" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b mb-6">
|
||||||
|
{['Overview', 'Reviews', 'Photos', 'History'].map((tab) => (
|
||||||
|
<div key={tab} className="h-10 bg-muted rounded w-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Grid */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Description Card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-3">
|
||||||
|
<div className="h-6 bg-muted rounded w-48 mb-4" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-4 bg-muted rounded w-5/6" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Technical Specs */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="h-6 bg-muted rounded w-56 mb-4" />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="h-3 bg-muted rounded w-24" />
|
||||||
|
<div className="h-5 bg-muted rounded w-32" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Ride Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="h-6 bg-muted rounded w-40 mb-4" />
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-4 h-4 bg-muted rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-muted rounded w-20 mb-1" />
|
||||||
|
<div className="h-3 bg-muted rounded w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Similar Rides */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="h-6 bg-muted rounded w-32 mb-4" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex gap-3">
|
||||||
|
<div className="w-16 h-16 bg-muted rounded" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded w-full" />
|
||||||
|
<div className="h-3 bg-muted rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Filter, MessageSquare, FileText, Image } from 'lucide-react';
|
import { Filter, MessageSquare, FileText, Image, Calendar } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { 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 {
|
interface ActiveFiltersDisplayProps {
|
||||||
entityFilter: EntityFilter;
|
entityFilter: EntityFilter;
|
||||||
statusFilter: StatusFilter;
|
statusFilter: StatusFilter;
|
||||||
|
approvalDateRange?: ApprovalDateRangeFilter;
|
||||||
defaultEntityFilter?: EntityFilter;
|
defaultEntityFilter?: EntityFilter;
|
||||||
defaultStatusFilter?: StatusFilter;
|
defaultStatusFilter?: StatusFilter;
|
||||||
}
|
}
|
||||||
@@ -23,12 +25,15 @@ const getEntityFilterIcon = (filter: EntityFilter) => {
|
|||||||
export const ActiveFiltersDisplay = ({
|
export const ActiveFiltersDisplay = ({
|
||||||
entityFilter,
|
entityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
approvalDateRange,
|
||||||
defaultEntityFilter = 'all',
|
defaultEntityFilter = 'all',
|
||||||
defaultStatusFilter = 'pending'
|
defaultStatusFilter = 'pending'
|
||||||
}: ActiveFiltersDisplayProps) => {
|
}: ActiveFiltersDisplayProps) => {
|
||||||
|
const hasDateRange = approvalDateRange && (approvalDateRange.from || approvalDateRange.to);
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
entityFilter !== defaultEntityFilter ||
|
entityFilter !== defaultEntityFilter ||
|
||||||
statusFilter !== defaultStatusFilter;
|
statusFilter !== defaultStatusFilter ||
|
||||||
|
hasDateRange;
|
||||||
|
|
||||||
if (!hasActiveFilters) return null;
|
if (!hasActiveFilters) return null;
|
||||||
|
|
||||||
@@ -46,6 +51,14 @@ export const ActiveFiltersDisplay = ({
|
|||||||
{statusFilter}
|
{statusFilter}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
54
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
54
src/components/moderation/DetailedViewCollapsible.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DetailedViewCollapsibleProps {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible wrapper for detailed field-by-field view sections
|
||||||
|
* Provides expand/collapse functionality with visual indicators
|
||||||
|
*/
|
||||||
|
export function DetailedViewCollapsible({
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: DetailedViewCollapsibleProps) {
|
||||||
|
return (
|
||||||
|
<Collapsible open={!isCollapsed} onOpenChange={() => onToggle()}>
|
||||||
|
<div className={cn("mt-6 pt-6 border-t", className)}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full flex items-center justify-between hover:bg-muted/50 p-2 h-auto"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
All Fields (Detailed View)
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground normal-case font-normal">
|
||||||
|
{isCollapsed ? 'Show' : 'Hide'}
|
||||||
|
</span>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent className="mt-3">
|
||||||
|
{children}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import { ArrowRight } from 'lucide-react';
|
|||||||
import { ArrayFieldDiff } from './ArrayFieldDiff';
|
import { ArrayFieldDiff } from './ArrayFieldDiff';
|
||||||
import { SpecialFieldDisplay } from './SpecialFieldDisplay';
|
import { SpecialFieldDisplay } from './SpecialFieldDisplay';
|
||||||
|
|
||||||
|
import type { DatePrecision } from '@/components/ui/flexible-date-input';
|
||||||
|
|
||||||
// Helper to format compact values (truncate long strings)
|
// Helper to format compact values (truncate long strings)
|
||||||
function formatCompactValue(value: unknown, precision?: 'day' | 'month' | 'year', maxLength = 30): string {
|
function formatCompactValue(value: unknown, precision?: DatePrecision, maxLength = 30): string {
|
||||||
const formatted = formatFieldValue(value, precision);
|
const formatted = formatFieldValue(value, precision);
|
||||||
if (formatted.length > maxLength) {
|
if (formatted.length > maxLength) {
|
||||||
return formatted.substring(0, maxLength) + '...';
|
return formatted.substring(0, maxLength) + '...';
|
||||||
|
|||||||
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
321
src/components/moderation/ItemApprovalHistory.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Item Approval History Component
|
||||||
|
*
|
||||||
|
* Displays detailed audit trail of approved items with exact timestamps.
|
||||||
|
* Features filtering, sorting, CSV export for compliance reporting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ExternalLink, Download, Clock, User, FileText } from 'lucide-react';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
import type { EntityType } from '@/types/submissions';
|
||||||
|
|
||||||
|
interface ApprovalHistoryItem {
|
||||||
|
item_id: string;
|
||||||
|
submission_id: string;
|
||||||
|
item_type: string;
|
||||||
|
action_type: string;
|
||||||
|
status: string;
|
||||||
|
approved_at: string;
|
||||||
|
approved_entity_id: string;
|
||||||
|
created_at: string;
|
||||||
|
approval_time_seconds: number;
|
||||||
|
submission_type: string;
|
||||||
|
submitter_username: string | null;
|
||||||
|
submitter_display_name: string | null;
|
||||||
|
submitter_avatar_url: string | null;
|
||||||
|
approver_username: string | null;
|
||||||
|
approver_display_name: string | null;
|
||||||
|
approver_avatar_url: string | null;
|
||||||
|
entity_slug: string | null;
|
||||||
|
entity_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemApprovalHistoryProps {
|
||||||
|
submissionId?: string;
|
||||||
|
dateRange?: { from: Date; to: Date };
|
||||||
|
itemType?: EntityType;
|
||||||
|
limit?: number;
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApprovalSpeed = (seconds: number) => {
|
||||||
|
const hours = seconds / 3600;
|
||||||
|
if (hours < 1) return { label: 'Fast', variant: 'default' as const, color: 'text-green-600 dark:text-green-400' };
|
||||||
|
if (hours < 24) return { label: 'Normal', variant: 'secondary' as const, color: 'text-blue-600 dark:text-blue-400' };
|
||||||
|
return { label: 'Slow', variant: 'destructive' as const, color: 'text-orange-600 dark:text-orange-400' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 48) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEntityPath = (itemType: string, slug: string | null) => {
|
||||||
|
if (!slug) return null;
|
||||||
|
|
||||||
|
switch (itemType) {
|
||||||
|
case 'park': return `/parks/${slug}/`;
|
||||||
|
case 'ride': return `/rides/${slug}`; // Need park slug ideally
|
||||||
|
case 'manufacturer':
|
||||||
|
case 'designer':
|
||||||
|
case 'operator':
|
||||||
|
return `/companies/${slug}/`;
|
||||||
|
case 'ride_model': return `/models/${slug}/`;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemApprovalHistory = ({
|
||||||
|
submissionId,
|
||||||
|
dateRange,
|
||||||
|
itemType,
|
||||||
|
limit = 100,
|
||||||
|
embedded = false
|
||||||
|
}: ItemApprovalHistoryProps) => {
|
||||||
|
const [sortField, setSortField] = useState<'approved_at' | 'approval_time_seconds'>('approved_at');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
const { data: history, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['approval-history', { submissionId, dateRange, itemType, limit }],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc('get_approval_history', {
|
||||||
|
p_item_type: itemType || undefined,
|
||||||
|
p_approver_id: undefined,
|
||||||
|
p_from_date: dateRange?.from?.toISOString() || undefined,
|
||||||
|
p_to_date: dateRange?.to?.toISOString() || undefined,
|
||||||
|
p_limit: limit,
|
||||||
|
p_offset: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Client-side filter by submission_id if provided
|
||||||
|
let filtered = data as ApprovalHistoryItem[];
|
||||||
|
if (submissionId) {
|
||||||
|
filtered = filtered.filter(item => item.submission_id === submissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
handleError(err, { action: 'fetch_approval_history' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedHistory = history ? [...history].sort((a, b) => {
|
||||||
|
const aVal = a[sortField];
|
||||||
|
const bVal = b[sortField];
|
||||||
|
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||||
|
return sortDirection === 'asc' ? comparison : -comparison;
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
const handleSort = (field: typeof sortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
if (!history || history.length === 0) return;
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Timestamp',
|
||||||
|
'Item Type',
|
||||||
|
'Action',
|
||||||
|
'Entity Name',
|
||||||
|
'Submitter',
|
||||||
|
'Approver',
|
||||||
|
'Time to Approve (hours)',
|
||||||
|
'Submission ID',
|
||||||
|
'Item ID'
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = history.map(item => [
|
||||||
|
format(new Date(item.approved_at), 'yyyy-MM-dd HH:mm:ss'),
|
||||||
|
item.item_type,
|
||||||
|
item.action_type,
|
||||||
|
item.entity_name || 'N/A',
|
||||||
|
item.submitter_display_name || item.submitter_username || 'Unknown',
|
||||||
|
item.approver_display_name || item.approver_username || 'Unknown',
|
||||||
|
(item.approval_time_seconds / 3600).toFixed(2),
|
||||||
|
item.submission_id,
|
||||||
|
item.item_id
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `approval-history-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className={embedded ? '' : 'mt-6'}>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-destructive">Failed to load approval history. Please try again.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{!embedded && (
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Item Approval History</CardTitle>
|
||||||
|
<CardDescription>Detailed audit trail of approved submissions</CardDescription>
|
||||||
|
</div>
|
||||||
|
{sortedHistory.length > 0 && (
|
||||||
|
<Button onClick={exportToCSV} variant="outline" size="sm">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={embedded ? 'p-0' : ''}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sortedHistory.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No approval history found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleSort('approved_at')}
|
||||||
|
>
|
||||||
|
Approved At {sortField === 'approved_at' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Entity</TableHead>
|
||||||
|
<TableHead>Submitter</TableHead>
|
||||||
|
<TableHead>Approver</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer hover:bg-muted/50 text-right"
|
||||||
|
onClick={() => handleSort('approval_time_seconds')}
|
||||||
|
>
|
||||||
|
Time to Approve {sortField === 'approval_time_seconds' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedHistory.map((item) => {
|
||||||
|
const speed = getApprovalSpeed(item.approval_time_seconds);
|
||||||
|
const entityPath = getEntityPath(item.item_type, item.entity_slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={item.item_id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{format(new Date(item.approved_at), 'MMM d, yyyy')}</span>
|
||||||
|
<span className="text-muted-foreground">{format(new Date(item.approved_at), 'HH:mm:ss')}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{item.item_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.entity_name ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{item.entity_name}</span>
|
||||||
|
{entityPath && (
|
||||||
|
<a
|
||||||
|
href={entityPath}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">N/A</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={item.submitter_avatar_url || undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(item.submitter_display_name || item.submitter_username || 'U')[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm">{item.submitter_display_name || item.submitter_username || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={item.approver_avatar_url || undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(item.approver_display_name || item.approver_username || 'M')[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm">{item.approver_display_name || item.approver_username || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Clock className={`w-4 h-4 ${speed.color}`} />
|
||||||
|
<span className="font-mono text-sm">{formatDuration(item.approval_time_seconds)}</span>
|
||||||
|
<Badge variant={speed.variant} className="ml-1">
|
||||||
|
{speed.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return embedded ? content : <Card className="mt-6">{content}</Card>;
|
||||||
|
};
|
||||||
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
125
src/components/moderation/ItemLevelApprovalHistory.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { CheckCircle2, User } from 'lucide-react';
|
||||||
|
import type { SubmissionItem } from '@/types/moderation';
|
||||||
|
|
||||||
|
interface ItemLevelApprovalHistoryProps {
|
||||||
|
items: SubmissionItem[];
|
||||||
|
reviewerProfile?: {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemLevelApprovalHistory = memo(({
|
||||||
|
items,
|
||||||
|
reviewerProfile,
|
||||||
|
}: ItemLevelApprovalHistoryProps) => {
|
||||||
|
// Filter to only approved items with timestamps
|
||||||
|
const approvedItems = items.filter(
|
||||||
|
item => item.status === 'approved' && (item as any).approved_at
|
||||||
|
);
|
||||||
|
|
||||||
|
if (approvedItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by approval time (newest first)
|
||||||
|
const sortedItems = [...approvedItems].sort((a, b) => {
|
||||||
|
const timeA = new Date((a as any).approved_at).getTime();
|
||||||
|
const timeB = new Date((b as any).approved_at).getTime();
|
||||||
|
return timeB - timeA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to get item display name
|
||||||
|
const getItemName = (item: SubmissionItem): string => {
|
||||||
|
const entityData = item.entity_data || item.item_data;
|
||||||
|
if (entityData && typeof entityData === 'object' && 'name' in entityData) {
|
||||||
|
return String(entityData.name);
|
||||||
|
}
|
||||||
|
return `${item.item_type} #${item.order_index}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get action label
|
||||||
|
const getActionLabel = (actionType: string): string => {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'create': return 'Created';
|
||||||
|
case 'edit': return 'Edited';
|
||||||
|
case 'delete': return 'Deleted';
|
||||||
|
default: return 'Modified';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
Item Approvals
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedItems.map((item) => {
|
||||||
|
const approvedAt = (item as any).approved_at;
|
||||||
|
const itemName = getItemName(item);
|
||||||
|
const actionLabel = getActionLabel(item.action_type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-3 text-sm bg-success/5 border border-success/20 rounded-md p-3"
|
||||||
|
>
|
||||||
|
{/* Approval Icon */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Info */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-foreground truncate">
|
||||||
|
{itemName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{actionLabel}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs font-mono">
|
||||||
|
{item.item_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDistanceToNow(new Date(approvedAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviewer Info */}
|
||||||
|
{reviewerProfile && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarImage src={reviewerProfile.avatar_url ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>
|
||||||
|
Approved by{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{reviewerProfile.display_name || reviewerProfile.username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ItemLevelApprovalHistory.displayName = 'ItemLevelApprovalHistory';
|
||||||
@@ -501,11 +501,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
activeEntityFilter={queueManager.filters.entityFilter}
|
activeEntityFilter={queueManager.filters.entityFilter}
|
||||||
activeStatusFilter={queueManager.filters.statusFilter}
|
activeStatusFilter={queueManager.filters.statusFilter}
|
||||||
sortConfig={queueManager.filters.sortConfig}
|
sortConfig={queueManager.filters.sortConfig}
|
||||||
|
activeTab={queueManager.filters.activeTab}
|
||||||
|
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||||
isMobile={isMobile ?? false}
|
isMobile={isMobile ?? false}
|
||||||
isLoading={queueManager.loadingState === 'loading'}
|
isLoading={queueManager.loadingState === 'loading'}
|
||||||
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
onEntityFilterChange={queueManager.filters.setEntityFilter}
|
||||||
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
onStatusFilterChange={queueManager.filters.setStatusFilter}
|
||||||
onSortChange={queueManager.filters.setSortConfig}
|
onSortChange={queueManager.filters.setSortConfig}
|
||||||
|
onApprovalDateRangeChange={queueManager.filters.setApprovalDateRange}
|
||||||
onClearFilters={queueManager.filters.clearFilters}
|
onClearFilters={queueManager.filters.clearFilters}
|
||||||
showClearButton={queueManager.filters.hasActiveFilters}
|
showClearButton={queueManager.filters.hasActiveFilters}
|
||||||
onRefresh={queueManager.refresh}
|
onRefresh={queueManager.refresh}
|
||||||
@@ -517,6 +520,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
|
|||||||
<ActiveFiltersDisplay
|
<ActiveFiltersDisplay
|
||||||
entityFilter={queueManager.filters.entityFilter}
|
entityFilter={queueManager.filters.entityFilter}
|
||||||
statusFilter={queueManager.filters.statusFilter}
|
statusFilter={queueManager.filters.statusFilter}
|
||||||
|
approvalDateRange={queueManager.filters.approvalDateRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
|
import { Filter, MessageSquare, FileText, Image, X, ChevronDown, Calendar } from 'lucide-react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -7,17 +7,21 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
|||||||
import { RefreshButton } from '@/components/ui/refresh-button';
|
import { RefreshButton } from '@/components/ui/refresh-button';
|
||||||
import { QueueSortControls } from './QueueSortControls';
|
import { QueueSortControls } from './QueueSortControls';
|
||||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||||
|
import type { EntityFilter, StatusFilter, SortConfig, QueueTab, ApprovalDateRangeFilter } from '@/types/moderation';
|
||||||
|
|
||||||
interface QueueFiltersProps {
|
interface QueueFiltersProps {
|
||||||
activeEntityFilter: EntityFilter;
|
activeEntityFilter: EntityFilter;
|
||||||
activeStatusFilter: StatusFilter;
|
activeStatusFilter: StatusFilter;
|
||||||
sortConfig: SortConfig;
|
sortConfig: SortConfig;
|
||||||
|
activeTab: QueueTab;
|
||||||
|
approvalDateRange: ApprovalDateRangeFilter;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEntityFilterChange: (filter: EntityFilter) => void;
|
onEntityFilterChange: (filter: EntityFilter) => void;
|
||||||
onStatusFilterChange: (filter: StatusFilter) => void;
|
onStatusFilterChange: (filter: StatusFilter) => void;
|
||||||
onSortChange: (config: SortConfig) => void;
|
onSortChange: (config: SortConfig) => void;
|
||||||
|
onApprovalDateRangeChange: (range: ApprovalDateRangeFilter) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
showClearButton: boolean;
|
showClearButton: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
@@ -37,11 +41,14 @@ export const QueueFilters = ({
|
|||||||
activeEntityFilter,
|
activeEntityFilter,
|
||||||
activeStatusFilter,
|
activeStatusFilter,
|
||||||
sortConfig,
|
sortConfig,
|
||||||
|
activeTab,
|
||||||
|
approvalDateRange,
|
||||||
isMobile,
|
isMobile,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onEntityFilterChange,
|
onEntityFilterChange,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
onApprovalDateRangeChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
showClearButton,
|
showClearButton,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -53,6 +60,7 @@ export const QueueFilters = ({
|
|||||||
const activeFilterCount = [
|
const activeFilterCount = [
|
||||||
activeEntityFilter !== 'all' ? 1 : 0,
|
activeEntityFilter !== 'all' ? 1 : 0,
|
||||||
activeStatusFilter !== 'all' ? 1 : 0,
|
activeStatusFilter !== 'all' ? 1 : 0,
|
||||||
|
approvalDateRange.from || approvalDateRange.to ? 1 : 0,
|
||||||
].reduce((sum, val) => sum + val, 0);
|
].reduce((sum, val) => sum + val, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,6 +172,21 @@ export const QueueFilters = ({
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isLoading={isLoading}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Clear Filters & Apply Buttons (mobile only) */}
|
{/* Clear Filters & Apply Buttons (mobile only) */}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { QueueItemActions } from './renderers/QueueItemActions';
|
|||||||
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
import { SubmissionMetadataPanel } from './SubmissionMetadataPanel';
|
||||||
import { AuditTrailViewer } from './AuditTrailViewer';
|
import { AuditTrailViewer } from './AuditTrailViewer';
|
||||||
import { RawDataViewer } from './RawDataViewer';
|
import { RawDataViewer } from './RawDataViewer';
|
||||||
|
import { ItemLevelApprovalHistory } from './ItemLevelApprovalHistory';
|
||||||
|
|
||||||
interface QueueItemProps {
|
interface QueueItemProps {
|
||||||
item: ModerationItem;
|
item: ModerationItem;
|
||||||
@@ -330,6 +331,15 @@ export const QueueItem = memo(({
|
|||||||
{item.type === 'content_submission' && (
|
{item.type === 'content_submission' && (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<SubmissionMetadataPanel item={item} />
|
<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} />
|
<AuditTrailViewer submissionId={item.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -211,7 +211,13 @@ function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: b
|
|||||||
{formatFieldName(change.field)}
|
{formatFieldName(change.field)}
|
||||||
{precision && (
|
{precision && (
|
||||||
<Badge variant="outline" className="text-xs ml-2">
|
<Badge variant="outline" className="text-xs ml-2">
|
||||||
{precision === 'year' ? 'Year Only' : precision === 'month' ? 'Month & Year' : 'Full Date'}
|
{precision === 'exact' ? 'Exact Day' :
|
||||||
|
precision === 'month' ? 'Month & Year' :
|
||||||
|
precision === 'year' ? 'Year Only' :
|
||||||
|
precision === 'decade' ? 'Decade' :
|
||||||
|
precision === 'century' ? 'Century' :
|
||||||
|
precision === 'approximate' ? 'Approximate' :
|
||||||
|
'Full Date'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RichRideDisplay } from './displays/RichRideDisplay';
|
|||||||
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
import { RichCompanyDisplay } from './displays/RichCompanyDisplay';
|
||||||
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
import { RichRideModelDisplay } from './displays/RichRideModelDisplay';
|
||||||
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
import { RichTimelineEventDisplay } from './displays/RichTimelineEventDisplay';
|
||||||
|
import { DetailedViewCollapsible } from './DetailedViewCollapsible';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -17,6 +18,7 @@ import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, Rid
|
|||||||
import type { TimelineSubmissionData } from '@/types/timeline';
|
import type { TimelineSubmissionData } from '@/types/timeline';
|
||||||
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
import { getErrorMessage, handleNonCriticalError } from '@/lib/errorHandler';
|
||||||
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
import { ModerationErrorBoundary } from '@/components/error/ModerationErrorBoundary';
|
||||||
|
import { useDetailedViewState } from '@/hooks/useDetailedViewState';
|
||||||
|
|
||||||
interface SubmissionItemsListProps {
|
interface SubmissionItemsListProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
@@ -34,6 +36,7 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { isCollapsed, toggle } = useDetailedViewState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubmissionItems();
|
fetchSubmissionItems();
|
||||||
@@ -188,17 +191,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as ParkSubmissionData}
|
data={entityData as unknown as ParkSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,17 +211,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as RideSubmissionData}
|
data={entityData as unknown as RideSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -234,17 +231,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as CompanySubmissionData}
|
data={entityData as unknown as CompanySubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -257,17 +251,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as RideModelSubmissionData}
|
data={entityData as unknown as RideModelSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,17 +271,14 @@ export const SubmissionItemsList = memo(function SubmissionItemsList({
|
|||||||
data={entityData as unknown as TimelineSubmissionData}
|
data={entityData as unknown as TimelineSubmissionData}
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 pt-6 border-t">
|
<DetailedViewCollapsible isCollapsed={isCollapsed} onToggle={toggle}>
|
||||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
||||||
All Fields (Detailed View)
|
|
||||||
</div>
|
|
||||||
<SubmissionChangesDisplay
|
<SubmissionChangesDisplay
|
||||||
item={item}
|
item={item}
|
||||||
view="detailed"
|
view="detailed"
|
||||||
showImages={showImages}
|
showImages={showImages}
|
||||||
submissionId={submissionId}
|
submissionId={submissionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailedViewCollapsible>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function RichCompanyDisplay({ data, actionType, showAllFields = true }: R
|
|||||||
{data.founded_date ? (
|
{data.founded_date ? (
|
||||||
<FlexibleDateDisplay
|
<FlexibleDateDisplay
|
||||||
date={data.founded_date}
|
date={data.founded_date}
|
||||||
precision={(data.founded_date_precision as DatePrecision) || 'day'}
|
precision={(data.founded_date_precision as DatePrecision) || 'exact'}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function RichParkDisplay({ data, actionType, showAllFields = true }: Rich
|
|||||||
<span className="text-muted-foreground">Opened:</span>{' '}
|
<span className="text-muted-foreground">Opened:</span>{' '}
|
||||||
<FlexibleDateDisplay
|
<FlexibleDateDisplay
|
||||||
date={data.opening_date}
|
date={data.opening_date}
|
||||||
precision={(data.opening_date_precision as DatePrecision) || 'day'}
|
precision={(data.opening_date_precision as DatePrecision) || 'exact'}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +175,7 @@ export function RichParkDisplay({ data, actionType, showAllFields = true }: Rich
|
|||||||
<span className="text-muted-foreground">Closed:</span>{' '}
|
<span className="text-muted-foreground">Closed:</span>{' '}
|
||||||
<FlexibleDateDisplay
|
<FlexibleDateDisplay
|
||||||
date={data.closing_date}
|
date={data.closing_date}
|
||||||
precision={(data.closing_date_precision as DatePrecision) || 'day'}
|
precision={(data.closing_date_precision as DatePrecision) || 'exact'}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -606,7 +606,7 @@ export function RichRideDisplay({ data, actionType, showAllFields = true }: Rich
|
|||||||
<span className="text-muted-foreground">Opened:</span>{' '}
|
<span className="text-muted-foreground">Opened:</span>{' '}
|
||||||
<FlexibleDateDisplay
|
<FlexibleDateDisplay
|
||||||
date={data.opening_date}
|
date={data.opening_date}
|
||||||
precision={(data.opening_date_precision as DatePrecision) || 'day'}
|
precision={(data.opening_date_precision as DatePrecision) || 'exact'}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,7 +616,7 @@ export function RichRideDisplay({ data, actionType, showAllFields = true }: Rich
|
|||||||
<span className="text-muted-foreground">Closed:</span>{' '}
|
<span className="text-muted-foreground">Closed:</span>{' '}
|
||||||
<FlexibleDateDisplay
|
<FlexibleDateDisplay
|
||||||
date={data.closing_date}
|
date={data.closing_date}
|
||||||
precision={(data.closing_date_precision as DatePrecision) || 'day'}
|
precision={(data.closing_date_precision as DatePrecision) || 'exact'}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
87
src/components/navigation/EntityBreadcrumb.tsx
Normal file
87
src/components/navigation/EntityBreadcrumb.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Home } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '@/components/ui/breadcrumb';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
|
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
|
||||||
|
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||||
|
|
||||||
|
interface BreadcrumbSegment {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
showPreview?: boolean;
|
||||||
|
previewType?: 'park' | 'company';
|
||||||
|
previewSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityBreadcrumbProps {
|
||||||
|
segments: BreadcrumbSegment[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityBreadcrumb({ segments, className }: EntityBreadcrumbProps) {
|
||||||
|
return (
|
||||||
|
<Breadcrumb className={className}>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{/* Home link */}
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link to="/" className="flex items-center gap-1 hover:text-primary transition-colors">
|
||||||
|
<Home className="w-3.5 h-3.5" />
|
||||||
|
<span>Home</span>
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
|
||||||
|
{segments.map((segment, index) => {
|
||||||
|
const isLast = index === segments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BreadcrumbItem key={index}>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
{isLast ? (
|
||||||
|
<BreadcrumbPage>{segment.label}</BreadcrumbPage>
|
||||||
|
) : segment.showPreview && segment.previewSlug ? (
|
||||||
|
<HoverCard openDelay={300}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link
|
||||||
|
to={segment.href || '#'}
|
||||||
|
className="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{segment.label}
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||||
|
{segment.previewType === 'park' && (
|
||||||
|
<ParkPreviewCard slug={segment.previewSlug} />
|
||||||
|
)}
|
||||||
|
{segment.previewType === 'company' && (
|
||||||
|
<CompanyPreviewCard slug={segment.previewSlug} />
|
||||||
|
)}
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link
|
||||||
|
to={segment.href || '#'}
|
||||||
|
className="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{segment.label}
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
80
src/components/preview/CompanyPreviewCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Building2, MapPin, Calendar } from 'lucide-react';
|
||||||
|
import { useCompanyPreview } from '@/hooks/preview/useCompanyPreview';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface CompanyPreviewCardProps {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyPreviewCard({ slug }: CompanyPreviewCardProps) {
|
||||||
|
const { data: company, isLoading } = useCompanyPreview(slug);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-16 bg-muted rounded" />
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return (
|
||||||
|
<div className="w-80 p-4 text-center text-muted-foreground">
|
||||||
|
Company not found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCompanyType = (type: string) => {
|
||||||
|
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-80 space-y-3">
|
||||||
|
{/* Header with logo */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{company.logo_url ? (
|
||||||
|
<img
|
||||||
|
src={company.logo_url}
|
||||||
|
alt={company.name}
|
||||||
|
className="w-12 h-12 object-contain rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 bg-muted rounded flex items-center justify-center">
|
||||||
|
<Building2 className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-base line-clamp-1">{company.name}</h3>
|
||||||
|
<Badge variant="secondary" className="mt-1">
|
||||||
|
{formatCompanyType(company.company_type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location and Founded */}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{company.headquarters_location && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="line-clamp-1">{company.headquarters_location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{company.founded_year && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>Founded {company.founded_year}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click to view full details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/components/preview/ParkPreviewCard.tsx
Normal file
112
src/components/preview/ParkPreviewCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { MapPin, Star, FerrisWheel, Zap } from 'lucide-react';
|
||||||
|
import { useParkPreview } from '@/hooks/preview/useParkPreview';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
interface ParkPreviewCardProps {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParkPreviewCard({ slug }: ParkPreviewCardProps) {
|
||||||
|
const { data: park, isLoading } = useParkPreview(slug);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-32 bg-muted rounded" />
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!park) {
|
||||||
|
return (
|
||||||
|
<div className="w-80 p-4 text-center text-muted-foreground">
|
||||||
|
Park not found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operating':
|
||||||
|
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||||
|
case 'seasonal':
|
||||||
|
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
|
||||||
|
case 'under_construction':
|
||||||
|
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatParkType = (type: string) => {
|
||||||
|
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-80 space-y-3">
|
||||||
|
{/* Image */}
|
||||||
|
{park.card_image_url && (
|
||||||
|
<div className="aspect-video rounded-lg overflow-hidden bg-muted">
|
||||||
|
<img
|
||||||
|
src={park.card_image_url}
|
||||||
|
alt={park.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-base line-clamp-1 mb-2">{park.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge className={`${getStatusColor(park.status)} border text-xs`}>
|
||||||
|
{park.status.replace('_', ' ').toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{formatParkType(park.park_type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{park.location && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="line-clamp-1">
|
||||||
|
{[park.location.city, park.location.state_province, park.location.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FerrisWheel className="w-4 h-4 text-primary" />
|
||||||
|
<span className="font-medium">{park.ride_count || 0}</span>
|
||||||
|
<span className="text-muted-foreground">rides</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-accent" />
|
||||||
|
<span className="font-medium">{park.coaster_count || 0}</span>
|
||||||
|
<span className="text-muted-foreground">coasters</span>
|
||||||
|
</div>
|
||||||
|
{park.average_rating && park.average_rating > 0 && (
|
||||||
|
<div className="flex items-center gap-2 col-span-2">
|
||||||
|
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||||
|
<span className="font-medium">{park.average_rating.toFixed(1)}</span>
|
||||||
|
<span className="text-muted-foreground">({park.review_count} reviews)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Ride } from '@/types/database';
|
import { Ride } from '@/types/database';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
|
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||||
|
|
||||||
interface RideListViewProps {
|
interface RideListViewProps {
|
||||||
rides: Ride[];
|
rides: Ride[];
|
||||||
@@ -115,10 +118,19 @@ export function RideListView({ rides, onRideClick }: RideListViewProps) {
|
|||||||
{formatCategory(ride.category)}
|
{formatCategory(ride.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{ride.manufacturer && (
|
{ride.manufacturer && (
|
||||||
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300">
|
<HoverCard openDelay={300}>
|
||||||
<Factory className="w-3 h-3 mr-1" />
|
<HoverCardTrigger asChild>
|
||||||
{ride.manufacturer.name}
|
<Link to={`/manufacturers/${ride.manufacturer.slug}`}>
|
||||||
</Badge>
|
<Badge variant="outline" className="text-xs backdrop-blur-sm border-accent/20 group-hover:border-accent/40 transition-colors duration-300 hover:bg-accent/10 cursor-pointer">
|
||||||
|
<Factory className="w-3 h-3 mr-1" />
|
||||||
|
{ride.manufacturer.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="top" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface TimelineEventCardProps {
|
|||||||
|
|
||||||
// ⚠️ IMPORTANT: Use parseDateForDisplay to prevent timezone shifts
|
// ⚠️ IMPORTANT: Use parseDateForDisplay to prevent timezone shifts
|
||||||
// YYYY-MM-DD strings must be interpreted as local dates, not UTC
|
// YYYY-MM-DD strings must be interpreted as local dates, not UTC
|
||||||
const formatEventDate = (date: string, precision: string = 'day') => {
|
const formatEventDate = (date: string, precision: string = 'exact') => {
|
||||||
const dateObj = parseDateForDisplay(date);
|
const dateObj = parseDateForDisplay(date);
|
||||||
|
|
||||||
switch (precision) {
|
switch (precision) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const timelineEventSchema = z.object({
|
|||||||
event_date: z.date({
|
event_date: z.date({
|
||||||
message: 'Event date is required',
|
message: 'Event date is required',
|
||||||
}),
|
}),
|
||||||
event_date_precision: z.enum(['day', 'month', 'year']).default('day'),
|
event_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).default('exact'),
|
||||||
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
|
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
|
||||||
description: z.string().max(1000, 'Description is too long').optional(),
|
description: z.string().max(1000, 'Description is too long').optional(),
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export function TimelineEventEditorDialog({
|
|||||||
} : {
|
} : {
|
||||||
event_type: 'milestone',
|
event_type: 'milestone',
|
||||||
event_date: new Date(),
|
event_date: new Date(),
|
||||||
event_date_precision: 'day',
|
event_date_precision: 'exact',
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
},
|
},
|
||||||
@@ -319,9 +319,12 @@ export function TimelineEventEditorDialog({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="day">Exact Day</SelectItem>
|
<SelectItem value="exact">Exact Day</SelectItem>
|
||||||
<SelectItem value="month">Month Only</SelectItem>
|
<SelectItem value="month">Month Only</SelectItem>
|
||||||
<SelectItem value="year">Year Only</SelectItem>
|
<SelectItem value="year">Year Only</SelectItem>
|
||||||
|
<SelectItem value="decade">Decade</SelectItem>
|
||||||
|
<SelectItem value="century">Century</SelectItem>
|
||||||
|
<SelectItem value="approximate">Approximate</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
264
src/components/ui/FormFieldWrapper.README.md
Normal file
264
src/components/ui/FormFieldWrapper.README.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# FormFieldWrapper Component
|
||||||
|
|
||||||
|
A unified form field component that automatically provides hints, validation messages, and terminology tooltips based on field type.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **Automatic hints** based on field type (speed, height, URL, email, etc.)
|
||||||
|
- ✅ **Built-in validation** display with error messages
|
||||||
|
- ✅ **Terminology tooltips** on labels (hover to see definitions)
|
||||||
|
- ✅ **Character counting** for textareas
|
||||||
|
- ✅ **50% less boilerplate** compared to manual field creation
|
||||||
|
- ✅ **Type-safe** with TypeScript
|
||||||
|
- ✅ **Consistent styling** across all forms
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Before (Manual)
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="website_url">Website URL</Label>
|
||||||
|
<Input
|
||||||
|
id="website_url"
|
||||||
|
type="url"
|
||||||
|
{...register('website_url')}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Official website URL (must start with https:// or http://)
|
||||||
|
</p>
|
||||||
|
{errors.website_url && (
|
||||||
|
<p className="text-sm text-destructive">{errors.website_url.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (With FormFieldWrapper)
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="website_url"
|
||||||
|
label="Website URL"
|
||||||
|
fieldType="url"
|
||||||
|
error={errors.website_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('website_url'),
|
||||||
|
placeholder: "https://..."
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormFieldWrapper } from '@/components/ui/form-field-wrapper';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const { register, formState: { errors } } = useForm();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
{/* Basic text input with automatic hint */}
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="email"
|
||||||
|
label="Email Address"
|
||||||
|
fieldType="email"
|
||||||
|
required
|
||||||
|
error={errors.email?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('email', { required: 'Email is required' }),
|
||||||
|
placeholder: "contact@example.com"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Textarea with character count */}
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="notes"
|
||||||
|
label="Notes for Reviewers"
|
||||||
|
fieldType="submission-notes"
|
||||||
|
optional
|
||||||
|
value={watch('notes')}
|
||||||
|
maxLength={1000}
|
||||||
|
error={errors.notes?.message as string}
|
||||||
|
textareaProps={{
|
||||||
|
...register('notes'),
|
||||||
|
placeholder: "Add context...",
|
||||||
|
rows: 3
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Terminology Tooltips
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="inversions"
|
||||||
|
label="Inversions"
|
||||||
|
fieldType="inversions"
|
||||||
|
termKey="inversion" // Adds tooltip explaining what inversions are
|
||||||
|
error={errors.inversions?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('inversions'),
|
||||||
|
type: "number",
|
||||||
|
placeholder: "e.g. 7"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Presets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormFieldWrapper, formFieldPresets } from '@/components/ui/form-field-wrapper';
|
||||||
|
|
||||||
|
<FormFieldWrapper
|
||||||
|
{...formFieldPresets.sourceUrl({})}
|
||||||
|
id="source_url"
|
||||||
|
error={errors.source_url?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('source_url'),
|
||||||
|
placeholder: "https://..."
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Field Types
|
||||||
|
|
||||||
|
- `url` - Website URLs with protocol hint
|
||||||
|
- `email` - Email addresses with format hint
|
||||||
|
- `phone` - Phone numbers with flexible format hint
|
||||||
|
- `slug` - URL slugs with character restrictions
|
||||||
|
- `height-requirement` - Height in cm with metric hint
|
||||||
|
- `age-requirement` - Age requirements
|
||||||
|
- `capacity` - Capacity per hour
|
||||||
|
- `duration` - Duration in seconds
|
||||||
|
- `speed` - Max speed (km/h)
|
||||||
|
- `height` - Max height (meters)
|
||||||
|
- `length` - Track length (meters)
|
||||||
|
- `inversions` - Number of inversions
|
||||||
|
- `g-force` - G-force values
|
||||||
|
- `source-url` - Reference URL for verification
|
||||||
|
- `submission-notes` - Notes for moderators (textarea with char count)
|
||||||
|
|
||||||
|
## Available Presets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
formFieldPresets.websiteUrl({})
|
||||||
|
formFieldPresets.email({})
|
||||||
|
formFieldPresets.phone({})
|
||||||
|
formFieldPresets.sourceUrl({})
|
||||||
|
formFieldPresets.submissionNotes({})
|
||||||
|
formFieldPresets.heightRequirement({})
|
||||||
|
formFieldPresets.capacity({})
|
||||||
|
formFieldPresets.duration({})
|
||||||
|
formFieldPresets.speed({})
|
||||||
|
formFieldPresets.height({})
|
||||||
|
formFieldPresets.length({})
|
||||||
|
formFieldPresets.inversions({})
|
||||||
|
formFieldPresets.gForce({})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Hints
|
||||||
|
|
||||||
|
Override automatic hints with custom text:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="custom"
|
||||||
|
label="Custom Field"
|
||||||
|
fieldType="text"
|
||||||
|
hint="This is my custom hint that overrides any automatic hint"
|
||||||
|
inputProps={{...register('custom')}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hide Hints
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="no_hint"
|
||||||
|
label="Field Without Hint"
|
||||||
|
fieldType="url"
|
||||||
|
hideHint
|
||||||
|
inputProps={{...register('no_hint')}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
To migrate existing fields:
|
||||||
|
|
||||||
|
1. **Identify the field structure** to replace
|
||||||
|
2. **Choose appropriate `fieldType`** from the list above
|
||||||
|
3. **Add `termKey`** if field relates to terminology
|
||||||
|
4. **Replace** the entire div block with `FormFieldWrapper`
|
||||||
|
|
||||||
|
Example migration:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BEFORE
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max_speed_kmh">Max Speed (km/h)</Label>
|
||||||
|
<Input
|
||||||
|
id="max_speed_kmh"
|
||||||
|
type="number"
|
||||||
|
{...register('max_speed_kmh')}
|
||||||
|
placeholder="e.g. 193"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Speed must be in km/h, between 0-500. Example: "193" for 193 km/h (120 mph)
|
||||||
|
</p>
|
||||||
|
{errors.max_speed_kmh && (
|
||||||
|
<p className="text-sm text-destructive">{errors.max_speed_kmh.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// AFTER
|
||||||
|
<FormFieldWrapper
|
||||||
|
id="max_speed_kmh"
|
||||||
|
label="Max Speed (km/h)"
|
||||||
|
fieldType="speed"
|
||||||
|
termKey="kilometers-per-hour"
|
||||||
|
error={errors.max_speed_kmh?.message as string}
|
||||||
|
inputProps={{
|
||||||
|
...register('max_speed_kmh'),
|
||||||
|
type: "number",
|
||||||
|
placeholder: "e.g. 193"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
View a live interactive demo at `/examples/form-field-wrapper` (in development mode) by visiting the `FormFieldWrapperDemo` component.
|
||||||
|
|
||||||
|
## Props Reference
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `id` | `string` | Field identifier (required) |
|
||||||
|
| `label` | `string` | Field label text (required) |
|
||||||
|
| `fieldType` | `FormFieldType` | Type for automatic hints |
|
||||||
|
| `termKey` | `string` | Terminology key for tooltip |
|
||||||
|
| `showTermIcon` | `boolean` | Show tooltip icon (default: true) |
|
||||||
|
| `required` | `boolean` | Show required asterisk |
|
||||||
|
| `optional` | `boolean` | Show optional badge |
|
||||||
|
| `hint` | `string` | Custom hint (overrides automatic) |
|
||||||
|
| `error` | `string` | Error message from validation |
|
||||||
|
| `value` | `string \| number` | Current value for char counting |
|
||||||
|
| `maxLength` | `number` | Max length for char counting |
|
||||||
|
| `inputProps` | `InputProps` | Props to pass to Input |
|
||||||
|
| `textareaProps` | `TextareaProps` | Props to pass to Textarea |
|
||||||
|
| `className` | `string` | Additional wrapper classes |
|
||||||
|
| `hideHint` | `boolean` | Hide automatic hint |
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Consistency** - All fields follow the same structure
|
||||||
|
2. **Less Code** - ~50% reduction in boilerplate
|
||||||
|
3. **Smart Defaults** - Automatic hints based on field type
|
||||||
|
4. **Built-in Terminology** - Hover tooltips for technical terms
|
||||||
|
5. **Easy Updates** - Change hints in one place, updates everywhere
|
||||||
|
6. **Type Safety** - TypeScript ensures correct usage
|
||||||
@@ -11,7 +11,7 @@ interface FlexibleDateDisplayProps {
|
|||||||
|
|
||||||
export function FlexibleDateDisplay({
|
export function FlexibleDateDisplay({
|
||||||
date,
|
date,
|
||||||
precision = 'day',
|
precision = 'exact',
|
||||||
fallback = 'Unknown',
|
fallback = 'Unknown',
|
||||||
className
|
className
|
||||||
}: FlexibleDateDisplayProps) {
|
}: FlexibleDateDisplayProps) {
|
||||||
@@ -36,7 +36,16 @@ export function FlexibleDateDisplay({
|
|||||||
case 'month':
|
case 'month':
|
||||||
formatted = format(dateObj, 'MMMM yyyy');
|
formatted = format(dateObj, 'MMMM yyyy');
|
||||||
break;
|
break;
|
||||||
case 'day':
|
case 'decade':
|
||||||
|
formatted = `${Math.floor(dateObj.getFullYear() / 10) * 10}s`;
|
||||||
|
break;
|
||||||
|
case 'century':
|
||||||
|
formatted = `${Math.ceil(dateObj.getFullYear() / 100)}th century`;
|
||||||
|
break;
|
||||||
|
case 'approximate':
|
||||||
|
formatted = `circa ${format(dateObj, 'yyyy')}`;
|
||||||
|
break;
|
||||||
|
case 'exact':
|
||||||
default:
|
default:
|
||||||
formatted = format(dateObj, 'PPP');
|
formatted = format(dateObj, 'PPP');
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon, Info } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { toDateOnly, toDateWithPrecision } from "@/lib/dateUtils";
|
import { toDateOnly, toDateWithPrecision } from "@/lib/dateUtils";
|
||||||
|
|
||||||
export type DatePrecision = 'day' | 'month' | 'year';
|
export type DatePrecision = 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
|
|
||||||
interface FlexibleDateInputProps {
|
interface FlexibleDateInputProps {
|
||||||
value?: Date;
|
value?: Date;
|
||||||
@@ -34,7 +34,7 @@ interface FlexibleDateInputProps {
|
|||||||
|
|
||||||
export function FlexibleDateInput({
|
export function FlexibleDateInput({
|
||||||
value,
|
value,
|
||||||
precision = 'day',
|
precision = 'exact',
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "Select date",
|
placeholder = "Select date",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -71,13 +71,16 @@ export function FlexibleDateInput({
|
|||||||
let newDate: Date;
|
let newDate: Date;
|
||||||
switch (newPrecision) {
|
switch (newPrecision) {
|
||||||
case 'year':
|
case 'year':
|
||||||
|
case 'decade':
|
||||||
|
case 'century':
|
||||||
|
case 'approximate':
|
||||||
newDate = new Date(year, 0, 1); // January 1st (local timezone)
|
newDate = new Date(year, 0, 1); // January 1st (local timezone)
|
||||||
setYearValue(year.toString());
|
setYearValue(year.toString());
|
||||||
break;
|
break;
|
||||||
case 'month':
|
case 'month':
|
||||||
newDate = new Date(year, month, 1); // 1st of month (local timezone)
|
newDate = new Date(year, month, 1); // 1st of month (local timezone)
|
||||||
break;
|
break;
|
||||||
case 'day':
|
case 'exact':
|
||||||
default:
|
default:
|
||||||
newDate = value; // Keep existing date
|
newDate = value; // Keep existing date
|
||||||
break;
|
break;
|
||||||
@@ -104,25 +107,47 @@ export function FlexibleDateInput({
|
|||||||
const getPlaceholderText = () => {
|
const getPlaceholderText = () => {
|
||||||
switch (localPrecision) {
|
switch (localPrecision) {
|
||||||
case 'year':
|
case 'year':
|
||||||
|
case 'decade':
|
||||||
|
case 'century':
|
||||||
|
case 'approximate':
|
||||||
return 'Enter year (e.g., 2005)';
|
return 'Enter year (e.g., 2005)';
|
||||||
case 'month':
|
case 'month':
|
||||||
return 'Select month and year';
|
return 'Select month and year';
|
||||||
case 'day':
|
case 'exact':
|
||||||
default:
|
default:
|
||||||
return placeholder;
|
return placeholder;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPrecisionHelpText = () => {
|
||||||
|
switch (localPrecision) {
|
||||||
|
case 'exact':
|
||||||
|
return 'Use when you know the specific day (e.g., June 15, 2010)';
|
||||||
|
case 'month':
|
||||||
|
return 'Use when you only know the month (e.g., June 2010)';
|
||||||
|
case 'year':
|
||||||
|
return 'Use when you only know the year (e.g., 2010)';
|
||||||
|
case 'decade':
|
||||||
|
return 'Use for events in a general decade (e.g., 1980s). Enter any year from that decade.';
|
||||||
|
case 'century':
|
||||||
|
return 'Use for very old dates spanning a century (e.g., 19th century). Enter any year from that century.';
|
||||||
|
case 'approximate':
|
||||||
|
return 'Use when the date is uncertain or estimated (e.g., circa 2010)';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<div className={cn("space-y-2", className)}>
|
||||||
{label && <Label>{label}</Label>}
|
{label && <Label>{label}</Label>}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{localPrecision === 'day' && (
|
{(localPrecision === 'exact') && (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={value}
|
date={value}
|
||||||
onSelect={(date) => onChange(date, 'day')}
|
onSelect={(date) => onChange(date, 'exact')}
|
||||||
placeholder={getPlaceholderText()}
|
placeholder={getPlaceholderText()}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
disableFuture={disableFuture}
|
disableFuture={disableFuture}
|
||||||
@@ -143,7 +168,7 @@ export function FlexibleDateInput({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localPrecision === 'year' && (
|
{(localPrecision === 'year' || localPrecision === 'decade' || localPrecision === 'century' || localPrecision === 'approximate') && (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={yearValue}
|
value={yearValue}
|
||||||
@@ -166,12 +191,20 @@ export function FlexibleDateInput({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="day">Use Full Date</SelectItem>
|
<SelectItem value="exact">Exact Day</SelectItem>
|
||||||
<SelectItem value="month">Use Month/Year</SelectItem>
|
<SelectItem value="month">Month & Year</SelectItem>
|
||||||
<SelectItem value="year">Use Year Only</SelectItem>
|
<SelectItem value="year">Year Only</SelectItem>
|
||||||
|
<SelectItem value="decade">Decade</SelectItem>
|
||||||
|
<SelectItem value="century">Century</SelectItem>
|
||||||
|
<SelectItem value="approximate">Approximate</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>{getPrecisionHelpText()}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
413
src/components/ui/form-field-wrapper.tsx
Normal file
413
src/components/ui/form-field-wrapper.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { TermTooltip } from "@/components/ui/term-tooltip";
|
||||||
|
import { fieldHints } from "@/lib/enhancedValidation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field types that automatically get hints and terminology support
|
||||||
|
*/
|
||||||
|
export type FormFieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'number'
|
||||||
|
| 'url'
|
||||||
|
| 'email'
|
||||||
|
| 'phone'
|
||||||
|
| 'textarea'
|
||||||
|
| 'slug'
|
||||||
|
| 'height-requirement'
|
||||||
|
| 'age-requirement'
|
||||||
|
| 'capacity'
|
||||||
|
| 'duration'
|
||||||
|
| 'speed'
|
||||||
|
| 'height'
|
||||||
|
| 'length'
|
||||||
|
| 'inversions'
|
||||||
|
| 'g-force'
|
||||||
|
| 'source-url'
|
||||||
|
| 'submission-notes';
|
||||||
|
|
||||||
|
interface FormFieldWrapperProps {
|
||||||
|
/** Field identifier */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Field label text */
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** Field type - determines automatic hints and validation */
|
||||||
|
fieldType?: FormFieldType;
|
||||||
|
|
||||||
|
/** Terminology key for tooltip (e.g., 'lsm', 'rmc') */
|
||||||
|
termKey?: string;
|
||||||
|
|
||||||
|
/** Show tooltip icon on label */
|
||||||
|
showTermIcon?: boolean;
|
||||||
|
|
||||||
|
/** Whether field is required */
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
/** Whether field is optional (shows badge) */
|
||||||
|
optional?: boolean;
|
||||||
|
|
||||||
|
/** Custom hint text (overrides automatic hint) */
|
||||||
|
hint?: string;
|
||||||
|
|
||||||
|
/** Error message from validation (pass errors.field?.message) */
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/** Current value for character counting */
|
||||||
|
value?: string | number;
|
||||||
|
|
||||||
|
/** Maximum length for character counting */
|
||||||
|
maxLength?: number;
|
||||||
|
|
||||||
|
/** Input props to pass through */
|
||||||
|
inputProps?: React.ComponentProps<typeof Input>;
|
||||||
|
|
||||||
|
/** Textarea props to pass through (when fieldType is 'textarea') */
|
||||||
|
textareaProps?: React.ComponentProps<typeof Textarea>;
|
||||||
|
|
||||||
|
/** Additional className for wrapper */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** Hide automatic hint */
|
||||||
|
hideHint?: boolean;
|
||||||
|
|
||||||
|
/** When to show validation feedback */
|
||||||
|
validationMode?: 'realtime' | 'onBlur';
|
||||||
|
|
||||||
|
/** Callback when field is blurred (for onBlur mode) */
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get automatic hint based on field type
|
||||||
|
*/
|
||||||
|
function getAutoHint(fieldType?: FormFieldType): string | undefined {
|
||||||
|
if (!fieldType) return undefined;
|
||||||
|
|
||||||
|
const hintMap: Record<FormFieldType, string | undefined> = {
|
||||||
|
'text': undefined,
|
||||||
|
'number': undefined,
|
||||||
|
'url': fieldHints.websiteUrl,
|
||||||
|
'email': fieldHints.email,
|
||||||
|
'phone': fieldHints.phone,
|
||||||
|
'textarea': undefined,
|
||||||
|
'slug': fieldHints.slug,
|
||||||
|
'height-requirement': fieldHints.heightRequirement,
|
||||||
|
'age-requirement': fieldHints.ageRequirement,
|
||||||
|
'capacity': fieldHints.capacity,
|
||||||
|
'duration': fieldHints.duration,
|
||||||
|
'speed': fieldHints.speed,
|
||||||
|
'height': fieldHints.height,
|
||||||
|
'length': fieldHints.length,
|
||||||
|
'inversions': fieldHints.inversions,
|
||||||
|
'g-force': fieldHints.gForce,
|
||||||
|
'source-url': fieldHints.sourceUrl,
|
||||||
|
'submission-notes': fieldHints.submissionNotes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return hintMap[fieldType];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get input type from field type
|
||||||
|
*/
|
||||||
|
function getInputType(fieldType?: FormFieldType): string {
|
||||||
|
if (!fieldType) return 'text';
|
||||||
|
|
||||||
|
const typeMap: Record<FormFieldType, string> = {
|
||||||
|
'text': 'text',
|
||||||
|
'number': 'number',
|
||||||
|
'url': 'url',
|
||||||
|
'email': 'email',
|
||||||
|
'phone': 'tel',
|
||||||
|
'textarea': 'text',
|
||||||
|
'slug': 'text',
|
||||||
|
'height-requirement': 'number',
|
||||||
|
'age-requirement': 'number',
|
||||||
|
'capacity': 'number',
|
||||||
|
'duration': 'number',
|
||||||
|
'speed': 'number',
|
||||||
|
'height': 'number',
|
||||||
|
'length': 'number',
|
||||||
|
'inversions': 'number',
|
||||||
|
'g-force': 'number',
|
||||||
|
'source-url': 'url',
|
||||||
|
'submission-notes': 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
return typeMap[fieldType] || 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified form field wrapper with automatic hints, validation, and terminology
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <FormFieldWrapper
|
||||||
|
* id="website_url"
|
||||||
|
* label="Website URL"
|
||||||
|
* fieldType="url"
|
||||||
|
* error={errors.website_url?.message}
|
||||||
|
* inputProps={{...register('website_url'), placeholder: "https://..."}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example With terminology tooltip
|
||||||
|
* ```tsx
|
||||||
|
* <FormFieldWrapper
|
||||||
|
* id="propulsion"
|
||||||
|
* label="Propulsion Method"
|
||||||
|
* fieldType="text"
|
||||||
|
* termKey="lsm"
|
||||||
|
* hint="Common: LSM Launch, Chain Lift, Hydraulic Launch"
|
||||||
|
* inputProps={{...register('propulsion')}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Textarea with character count
|
||||||
|
* ```tsx
|
||||||
|
* <FormFieldWrapper
|
||||||
|
* id="notes"
|
||||||
|
* label="Notes"
|
||||||
|
* fieldType="submission-notes"
|
||||||
|
* optional
|
||||||
|
* value={watch('notes')}
|
||||||
|
* maxLength={1000}
|
||||||
|
* textareaProps={{...register('notes'), rows: 3}}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function FormFieldWrapper({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
fieldType,
|
||||||
|
termKey,
|
||||||
|
showTermIcon = true,
|
||||||
|
required = false,
|
||||||
|
optional = false,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
value,
|
||||||
|
maxLength,
|
||||||
|
inputProps,
|
||||||
|
textareaProps,
|
||||||
|
className,
|
||||||
|
hideHint = false,
|
||||||
|
validationMode = 'realtime',
|
||||||
|
onBlur,
|
||||||
|
}: FormFieldWrapperProps) {
|
||||||
|
const [hasBlurred, setHasBlurred] = React.useState(false);
|
||||||
|
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
|
||||||
|
const autoHint = getAutoHint(fieldType);
|
||||||
|
const displayHint = hint || autoHint;
|
||||||
|
const inputType = getInputType(fieldType);
|
||||||
|
|
||||||
|
// Character count for textareas with maxLength
|
||||||
|
const showCharCount = isTextarea && maxLength && typeof value === 'string';
|
||||||
|
const charCount = typeof value === 'string' ? value.length : 0;
|
||||||
|
|
||||||
|
// Determine validation state
|
||||||
|
const shouldShowValidation = validationMode === 'realtime' || (validationMode === 'onBlur' && hasBlurred);
|
||||||
|
const hasValue = value !== undefined && value !== null && value !== '';
|
||||||
|
const isValid = shouldShowValidation && !error && hasValue;
|
||||||
|
const hasError = shouldShowValidation && !!error;
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setHasBlurred(true);
|
||||||
|
if (validationMode === 'onBlur' && onBlur) {
|
||||||
|
onBlur();
|
||||||
|
}
|
||||||
|
// Call original onBlur if provided
|
||||||
|
if ('value' in e.target && textareaProps?.onBlur) {
|
||||||
|
textareaProps.onBlur(e as React.FocusEvent<HTMLTextAreaElement>);
|
||||||
|
} else if (inputProps?.onBlur) {
|
||||||
|
inputProps.onBlur(e as React.FocusEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)} data-error={hasError ? "true" : undefined}>
|
||||||
|
{/* Label with optional terminology tooltip */}
|
||||||
|
<Label htmlFor={id} className="flex items-center gap-2">
|
||||||
|
{termKey ? (
|
||||||
|
<TermTooltip term={termKey} showIcon={showTermIcon}>
|
||||||
|
{label}
|
||||||
|
</TermTooltip>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{required && <span className="text-destructive">*</span>}
|
||||||
|
{optional && (
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* Input or Textarea with validation icons */}
|
||||||
|
<div className="relative">
|
||||||
|
{isTextarea ? (
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"pr-10 transition-all duration-300 ease-in-out",
|
||||||
|
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||||
|
error && "border-destructive focus:ring-destructive/20",
|
||||||
|
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||||
|
)}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...textareaProps}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type={inputType}
|
||||||
|
className={cn(
|
||||||
|
"pr-10 transition-all duration-300 ease-in-out",
|
||||||
|
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||||
|
error && "border-destructive focus:ring-destructive/20",
|
||||||
|
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||||
|
)}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation icon with animation */}
|
||||||
|
{(isValid || hasError) && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
{isValid && (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500 animate-fade-in" />
|
||||||
|
)}
|
||||||
|
{hasError && (
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive animate-fade-in" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hint text (if not hidden and exists) */}
|
||||||
|
{!hideHint && displayHint && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground animate-slide-in-down">
|
||||||
|
{displayHint}
|
||||||
|
{showCharCount && ` (${charCount}/${maxLength} characters)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Character count only (when no hint) */}
|
||||||
|
{!hideHint && !displayHint && showCharCount && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{charCount}/{maxLength} characters
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message with animation */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive animate-slide-in-down">
|
||||||
|
{error}
|
||||||
|
{showCharCount && ` (${charCount}/${maxLength})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset configurations for common field types
|
||||||
|
*/
|
||||||
|
export const formFieldPresets = {
|
||||||
|
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'url' as FormFieldType,
|
||||||
|
label: 'Website URL',
|
||||||
|
validationMode: 'onBlur',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
email: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'email' as FormFieldType,
|
||||||
|
label: 'Email',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
phone: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'phone' as FormFieldType,
|
||||||
|
label: 'Phone Number',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
sourceUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'source-url' as FormFieldType,
|
||||||
|
label: 'Source URL',
|
||||||
|
optional: true,
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
submissionNotes: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'submission-notes' as FormFieldType,
|
||||||
|
label: 'Notes for Reviewers',
|
||||||
|
optional: true,
|
||||||
|
maxLength: 1000,
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
heightRequirement: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'height-requirement' as FormFieldType,
|
||||||
|
label: 'Height Requirement',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
capacity: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'capacity' as FormFieldType,
|
||||||
|
label: 'Capacity per Hour',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
duration: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'duration' as FormFieldType,
|
||||||
|
label: 'Duration (seconds)',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
speed: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'speed' as FormFieldType,
|
||||||
|
label: 'Max Speed',
|
||||||
|
termKey: 'kilometers-per-hour',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
height: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'height' as FormFieldType,
|
||||||
|
label: 'Max Height',
|
||||||
|
termKey: 'meters',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
length: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'length' as FormFieldType,
|
||||||
|
label: 'Track Length',
|
||||||
|
termKey: 'meters',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
inversions: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'inversions' as FormFieldType,
|
||||||
|
label: 'Inversions',
|
||||||
|
termKey: 'inversion',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
|
||||||
|
gForce: (props: Partial<FormFieldWrapperProps>) => ({
|
||||||
|
fieldType: 'g-force' as FormFieldType,
|
||||||
|
label: 'Max G-Force',
|
||||||
|
termKey: 'g-force',
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
};
|
||||||
56
src/components/ui/term-tooltip.tsx
Normal file
56
src/components/ui/term-tooltip.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getGlossaryTerm } from "@/lib/glossary";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TermTooltipProps {
|
||||||
|
term: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
inline?: boolean;
|
||||||
|
showIcon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermTooltip({ term, children, inline = false, showIcon = true }: TermTooltipProps) {
|
||||||
|
const glossaryEntry = getGlossaryTerm(term);
|
||||||
|
|
||||||
|
if (!glossaryEntry) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
inline && "underline decoration-dotted cursor-help"
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
{showIcon && (
|
||||||
|
<HelpCircle className="inline-block w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs" side="top">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-semibold text-sm">{glossaryEntry.term}</div>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
|
{glossaryEntry.category.replace('-', ' ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{glossaryEntry.definition}</p>
|
||||||
|
{glossaryEntry.example && (
|
||||||
|
<p className="text-xs text-muted-foreground italic pt-1">
|
||||||
|
Example: {glossaryEntry.example}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{glossaryEntry.relatedTerms && glossaryEntry.relatedTerms.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground pt-1">
|
||||||
|
See also: {glossaryEntry.relatedTerms.map(t =>
|
||||||
|
getGlossaryTerm(t)?.term || t
|
||||||
|
).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/ui/validated-input.tsx
Normal file
87
src/components/ui/validated-input.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Check, AlertCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
export interface ValidatedInputProps extends React.ComponentProps<typeof Input> {
|
||||||
|
validation?: {
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
showValidation?: boolean;
|
||||||
|
onValidate?: (value: string) => { isValid: boolean; error?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidatedInput = React.forwardRef<HTMLInputElement, ValidatedInputProps>(
|
||||||
|
({ className, validation, showValidation = true, onValidate, onChange, ...props }, ref) => {
|
||||||
|
const [localValidation, setLocalValidation] = React.useState<{
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const debouncedValidate = useDebouncedCallback((value: string) => {
|
||||||
|
if (onValidate) {
|
||||||
|
const result = onValidate(value);
|
||||||
|
setLocalValidation(result);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e);
|
||||||
|
if (onValidate && showValidation) {
|
||||||
|
debouncedValidate(e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationState = validation || localValidation;
|
||||||
|
const showSuccess = showValidation && validationState.isValid && props.value;
|
||||||
|
const showError = showValidation && validationState.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
showError && "border-destructive focus-visible:ring-destructive",
|
||||||
|
showSuccess && "border-green-500 focus-visible:ring-green-500",
|
||||||
|
"pr-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{showValidation && props.value && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
{validationState.isValid && (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
{validationState.error && (
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showValidation && validation?.hint && !validationState.error && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{validation.hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showError && (
|
||||||
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{validationState.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ValidatedInput.displayName = "ValidatedInput";
|
||||||
|
|
||||||
|
export { ValidatedInput };
|
||||||
@@ -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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { 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 {
|
interface RollbackDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
entityType: string;
|
entityType: EntityType;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
entityName: string;
|
entityName: string;
|
||||||
onRollback: (reason: string) => Promise<void>;
|
onRollback: (reason: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VersionDiff {
|
||||||
|
[fieldName: string]: {
|
||||||
|
from: unknown;
|
||||||
|
to: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function RollbackDialog({
|
export function RollbackDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
versionId,
|
versionId,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
entityName,
|
entityName,
|
||||||
onRollback,
|
onRollback,
|
||||||
}: RollbackDialogProps) {
|
}: RollbackDialogProps) {
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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 () => {
|
const handleRollback = async () => {
|
||||||
if (!reason.trim()) return;
|
if (!reason.trim()) return;
|
||||||
@@ -33,15 +73,32 @@ export function RollbackDialog({
|
|||||||
try {
|
try {
|
||||||
await onRollback(reason);
|
await onRollback(reason);
|
||||||
setReason('');
|
setReason('');
|
||||||
|
setDiff(null);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Restore Previous Version (Moderator Action)</DialogTitle>
|
<DialogTitle>Restore Previous Version (Moderator Action)</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -56,6 +113,100 @@ export function RollbackDialog({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="rollback-reason">Reason for rollback *</Label>
|
<Label htmlFor="rollback-reason">Reason for rollback *</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
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 { formatDistanceToNow } from 'date-fns';
|
||||||
import { EntityVersionHistory } from './EntityVersionHistory';
|
import { EntityVersionHistory } from './EntityVersionHistory';
|
||||||
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
import { useEntityVersions } from '@/hooks/useEntityVersions';
|
||||||
@@ -43,7 +42,7 @@ export function VersionIndicator({
|
|||||||
>
|
>
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
v{currentVersion.version_number}
|
History
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -66,10 +65,6 @@ export function VersionIndicator({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<span className="text-sm text-muted-foreground">
|
||||||
Last edited {timeAgo}
|
Last edited {timeAgo}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
135
src/hooks/admin/useDatabaseMaintenance.ts
Normal file
135
src/hooks/admin/useDatabaseMaintenance.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface MaintenanceTable {
|
||||||
|
table_name: string;
|
||||||
|
row_count: number;
|
||||||
|
table_size: string;
|
||||||
|
indexes_size: string;
|
||||||
|
total_size: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceResult {
|
||||||
|
table_name: string;
|
||||||
|
operation: string;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string;
|
||||||
|
duration_ms: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMaintenanceTables() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.admin.maintenanceTables(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase.rpc('get_maintenance_tables');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as unknown as MaintenanceTable[];
|
||||||
|
},
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVacuumTable() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (tableName: string) => {
|
||||||
|
const { data, error } = await supabase.rpc('run_vacuum_table', {
|
||||||
|
table_name: tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as unknown as MaintenanceResult;
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Vacuum completed on ${result.table_name}`, {
|
||||||
|
description: `Duration: ${Math.round(result.duration_ms)}ms`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`Vacuum failed on ${result.table_name}`, {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Vacuum operation failed', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalyzeTable() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (tableName: string) => {
|
||||||
|
const { data, error } = await supabase.rpc('run_analyze_table', {
|
||||||
|
table_name: tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as unknown as MaintenanceResult;
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Analyze completed on ${result.table_name}`, {
|
||||||
|
description: `Duration: ${Math.round(result.duration_ms)}ms`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`Analyze failed on ${result.table_name}`, {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Analyze operation failed', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReindexTable() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (tableName: string) => {
|
||||||
|
const { data, error } = await supabase.rpc('run_reindex_table', {
|
||||||
|
table_name: tableName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as unknown as MaintenanceResult;
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Reindex completed on ${result.table_name}`, {
|
||||||
|
description: `Duration: ${Math.round(result.duration_ms)}ms`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`Reindex failed on ${result.table_name}`, {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.admin.maintenanceTables() });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Reindex operation failed', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
itemIds: string[],
|
itemIds: string[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
maxConflictRetries: number = 3,
|
maxConflictRetries: number = 3,
|
||||||
timeoutMs: number = 30000
|
timeoutMs: number = 60000 // Increased from 30s to 60s
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: T | null;
|
data: T | null;
|
||||||
error: any;
|
error: any;
|
||||||
@@ -337,7 +337,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
submissionItems.map((i) => i.id),
|
submissionItems.map((i) => i.id),
|
||||||
config.user?.id,
|
config.user?.id,
|
||||||
3, // Max 3 conflict retries
|
3, // Max 3 conflict retries
|
||||||
30000 // 30s timeout
|
60000 // 60s timeout (increased for slow queries)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log retry attempts
|
// Log retry attempts
|
||||||
@@ -393,7 +393,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
submissionItems.map((i) => i.id),
|
submissionItems.map((i) => i.id),
|
||||||
config.user?.id,
|
config.user?.id,
|
||||||
3, // Max 3 conflict retries
|
3, // Max 3 conflict retries
|
||||||
30000 // 30s timeout
|
60000 // 60s timeout (increased for slow queries)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log retry attempts
|
// Log retry attempts
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useState, useCallback, useEffect } from 'react';
|
|||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
|
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';
|
import * as storage from '@/lib/localStorage';
|
||||||
|
|
||||||
export interface ModerationFiltersConfig {
|
export interface ModerationFiltersConfig {
|
||||||
@@ -36,6 +36,9 @@ export interface ModerationFiltersConfig {
|
|||||||
|
|
||||||
/** Initial sort configuration */
|
/** Initial sort configuration */
|
||||||
initialSortConfig?: SortConfig;
|
initialSortConfig?: SortConfig;
|
||||||
|
|
||||||
|
/** Initial approval date range filter */
|
||||||
|
initialApprovalDateRange?: ApprovalDateRangeFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModerationFilters {
|
export interface ModerationFilters {
|
||||||
@@ -87,6 +90,15 @@ export interface ModerationFilters {
|
|||||||
/** Reset sort to default */
|
/** Reset sort to default */
|
||||||
resetSort: () => void;
|
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) */
|
/** Reset pagination to page 1 (callback) */
|
||||||
onFilterChange?: () => void;
|
onFilterChange?: () => void;
|
||||||
}
|
}
|
||||||
@@ -121,6 +133,7 @@ export function useModerationFilters(
|
|||||||
persist = true,
|
persist = true,
|
||||||
storageKey = 'moderationQueue_filters',
|
storageKey = 'moderationQueue_filters',
|
||||||
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
initialSortConfig = { field: 'created_at', direction: 'asc' },
|
||||||
|
initialApprovalDateRange = { from: null, to: null },
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
@@ -174,6 +187,9 @@ export function useModerationFilters(
|
|||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
const [sortConfig, setSortConfigState] = useState<SortConfig>(loadPersistedSort);
|
||||||
|
|
||||||
|
// Approval date range state
|
||||||
|
const [approvalDateRange, setApprovalDateRangeState] = useState<ApprovalDateRangeFilter>(initialApprovalDateRange);
|
||||||
|
|
||||||
// Debounced filters for API calls
|
// Debounced filters for API calls
|
||||||
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
const debouncedEntityFilter = useDebounce(entityFilter, debounceDelay);
|
||||||
@@ -181,6 +197,9 @@ export function useModerationFilters(
|
|||||||
|
|
||||||
// Debounced sort (0ms for immediate feedback)
|
// Debounced sort (0ms for immediate feedback)
|
||||||
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
const debouncedSortConfig = useDebounce(sortConfig, 0);
|
||||||
|
|
||||||
|
// Debounced approval date range
|
||||||
|
const debouncedApprovalDateRange = useDebounce(approvalDateRange, debounceDelay);
|
||||||
|
|
||||||
// Persist filters to localStorage
|
// Persist filters to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -246,6 +265,13 @@ export function useModerationFilters(
|
|||||||
const resetSort = useCallback(() => {
|
const resetSort = useCallback(() => {
|
||||||
setSortConfigState(initialSortConfig);
|
setSortConfigState(initialSortConfig);
|
||||||
}, [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
|
// Clear all filters
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
@@ -254,7 +280,8 @@ export function useModerationFilters(
|
|||||||
setStatusFilterState(initialStatusFilter);
|
setStatusFilterState(initialStatusFilter);
|
||||||
setActiveTabState(initialTab);
|
setActiveTabState(initialTab);
|
||||||
setSortConfigState(initialSortConfig);
|
setSortConfigState(initialSortConfig);
|
||||||
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig]);
|
setApprovalDateRangeState(initialApprovalDateRange);
|
||||||
|
}, [initialEntityFilter, initialStatusFilter, initialTab, initialSortConfig, initialApprovalDateRange]);
|
||||||
|
|
||||||
// Check if non-default filters are active
|
// Check if non-default filters are active
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
@@ -262,7 +289,9 @@ export function useModerationFilters(
|
|||||||
statusFilter !== initialStatusFilter ||
|
statusFilter !== initialStatusFilter ||
|
||||||
activeTab !== initialTab ||
|
activeTab !== initialTab ||
|
||||||
sortConfig.field !== initialSortConfig.field ||
|
sortConfig.field !== initialSortConfig.field ||
|
||||||
sortConfig.direction !== initialSortConfig.direction;
|
sortConfig.direction !== initialSortConfig.direction ||
|
||||||
|
approvalDateRange.from !== null ||
|
||||||
|
approvalDateRange.to !== null;
|
||||||
|
|
||||||
// Return without useMemo wrapper (OPTIMIZED)
|
// Return without useMemo wrapper (OPTIMIZED)
|
||||||
return {
|
return {
|
||||||
@@ -282,6 +311,9 @@ export function useModerationFilters(
|
|||||||
sortBy,
|
sortBy,
|
||||||
toggleSortDirection,
|
toggleSortDirection,
|
||||||
resetSort,
|
resetSort,
|
||||||
|
approvalDateRange,
|
||||||
|
debouncedApprovalDateRange,
|
||||||
|
setApprovalDateRange,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
currentPage: pagination.currentPage,
|
currentPage: pagination.currentPage,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
sortConfig: filters.debouncedSortConfig,
|
sortConfig: filters.debouncedSortConfig,
|
||||||
|
approvalDateRange: filters.debouncedApprovalDateRange,
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export interface UseQueueQueryConfig {
|
|||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Approval date range filter */
|
||||||
|
approvalDateRange?: {
|
||||||
|
from: Date | null;
|
||||||
|
to: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** Whether query is enabled (defaults to true) */
|
/** Whether query is enabled (defaults to true) */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -145,6 +151,7 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
|||||||
currentPage: config.currentPage,
|
currentPage: config.currentPage,
|
||||||
pageSize: config.pageSize,
|
pageSize: config.pageSize,
|
||||||
sortConfig: config.sortConfig,
|
sortConfig: config.sortConfig,
|
||||||
|
approvalDateRange: config.approvalDateRange,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
||||||
@@ -161,6 +168,8 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
|
|||||||
config.pageSize,
|
config.pageSize,
|
||||||
config.sortConfig.field,
|
config.sortConfig.field,
|
||||||
config.sortConfig.direction,
|
config.sortConfig.direction,
|
||||||
|
config.approvalDateRange?.from?.toISOString(),
|
||||||
|
config.approvalDateRange?.to?.toISOString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Execute query
|
// Execute query
|
||||||
|
|||||||
36
src/hooks/preview/useCompanyPreview.ts
Normal file
36
src/hooks/preview/useCompanyPreview.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch company preview data for hover cards
|
||||||
|
*/
|
||||||
|
export function useCompanyPreview(slug: string | undefined, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.companies.detail(slug || ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!slug) throw new Error('Slug is required');
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('companies')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
company_type,
|
||||||
|
person_type,
|
||||||
|
headquarters_location,
|
||||||
|
founded_year,
|
||||||
|
logo_url
|
||||||
|
`)
|
||||||
|
.eq('slug', slug)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enabled && !!slug,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
39
src/hooks/preview/useParkPreview.ts
Normal file
39
src/hooks/preview/useParkPreview.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch park preview data for hover cards
|
||||||
|
*/
|
||||||
|
export function useParkPreview(slug: string | undefined, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.parks.detail(slug || ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!slug) throw new Error('Slug is required');
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
park_type,
|
||||||
|
status,
|
||||||
|
card_image_url,
|
||||||
|
ride_count,
|
||||||
|
coaster_count,
|
||||||
|
average_rating,
|
||||||
|
review_count,
|
||||||
|
location:locations(city, state_province, country)
|
||||||
|
`)
|
||||||
|
.eq('slug', slug)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enabled && !!slug,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
import type { DatabaseStatistics } from '@/types/database-stats';
|
import type { DatabaseStatistics } from '@/types/database-stats';
|
||||||
|
|
||||||
export function useAdminDatabaseStats() {
|
export function useAdminDatabaseStats() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.admin.databaseStats(),
|
queryKey: queryKeys.admin.databaseStats(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -15,7 +19,8 @@ export function useAdminDatabaseStats() {
|
|||||||
|
|
||||||
return data as unknown as DatabaseStatistics;
|
return data as unknown as DatabaseStatistics;
|
||||||
},
|
},
|
||||||
|
enabled: isAdminPage, // Only run query on admin pages
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchInterval: 60 * 1000, // Auto-refetch every 60 seconds
|
refetchInterval: isAdminPage ? 60 * 1000 : false, // Only refetch on admin pages
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { CompletenessAnalysis, CompletenessFilters } from '@/types/data-completeness';
|
import type { CompletenessAnalysis, CompletenessFilters } from '@/types/data-completeness';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
|
||||||
export function useDataCompleteness(filters: CompletenessFilters = {}) {
|
export function useDataCompleteness(filters: CompletenessFilters = {}) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -40,6 +43,7 @@ export function useDataCompleteness(filters: CompletenessFilters = {}) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enabled: isAdminPage, // Only run on admin pages
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|||||||
48
src/hooks/useDetailedViewState.ts
Normal file
48
src/hooks/useDetailedViewState.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'detailed-view-collapsed';
|
||||||
|
|
||||||
|
interface UseDetailedViewStateReturn {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
setCollapsed: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage detailed view collapsed/expanded state
|
||||||
|
* Syncs with localStorage for persistence across sessions
|
||||||
|
* Defaults to collapsed to reduce visual clutter
|
||||||
|
*/
|
||||||
|
export function useDetailedViewState(): UseDetailedViewStateReturn {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||||
|
// Initialize from localStorage on mount
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
// Default to collapsed (true) to reduce visual clutter
|
||||||
|
return stored ? JSON.parse(stored) : true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error reading detailed view state from localStorage', { error });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync to localStorage when state changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error saving detailed view state to localStorage', { error });
|
||||||
|
}
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
const toggle = () => setIsCollapsed(prev => !prev);
|
||||||
|
|
||||||
|
const setCollapsed = (value: boolean) => setIsCollapsed(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCollapsed,
|
||||||
|
toggle,
|
||||||
|
setCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { queryKeys } from '@/lib/queryKeys';
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
import type { RecentAddition } from '@/types/database-stats';
|
import type { RecentAddition } from '@/types/database-stats';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) {
|
export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: queryKeys.admin.recentAdditions(limit),
|
queryKey: queryKeys.admin.recentAdditions(limit),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -18,8 +22,9 @@ export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string
|
|||||||
|
|
||||||
return data as unknown as RecentAddition[];
|
return data as unknown as RecentAddition[];
|
||||||
},
|
},
|
||||||
|
enabled: isAdminPage, // Only run query on admin pages
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
refetchInterval: isAdminPage ? 30 * 1000 : false, // Only refetch on admin pages
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up real-time subscriptions
|
// Set up real-time subscriptions
|
||||||
@@ -51,7 +56,7 @@ export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string
|
|||||||
.subscribe(),
|
.subscribe(),
|
||||||
supabase
|
supabase
|
||||||
.channel('recent_additions_photos')
|
.channel('recent_additions_photos')
|
||||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'entity_photos' }, () => {
|
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'photos' }, () => {
|
||||||
query.refetch();
|
query.refetch();
|
||||||
})
|
})
|
||||||
.subscribe(),
|
.subscribe(),
|
||||||
|
|||||||
@@ -1872,6 +1872,13 @@ export type Database = {
|
|||||||
item_id?: string
|
item_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
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"
|
foreignKeyName: "item_edit_history_item_id_fkey"
|
||||||
columns: ["item_id"]
|
columns: ["item_id"]
|
||||||
@@ -5682,6 +5689,13 @@ export type Database = {
|
|||||||
submission_item_id?: string
|
submission_item_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
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"
|
foreignKeyName: "submission_item_temp_refs_submission_item_id_fkey"
|
||||||
columns: ["submission_item_id"]
|
columns: ["submission_item_id"]
|
||||||
@@ -5694,6 +5708,7 @@ export type Database = {
|
|||||||
submission_items: {
|
submission_items: {
|
||||||
Row: {
|
Row: {
|
||||||
action_type: string | null
|
action_type: string | null
|
||||||
|
approved_at: string | null
|
||||||
approved_entity_id: string | null
|
approved_entity_id: string | null
|
||||||
company_submission_id: string | null
|
company_submission_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -5714,6 +5729,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
action_type?: string | null
|
action_type?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
approved_entity_id?: string | null
|
approved_entity_id?: string | null
|
||||||
company_submission_id?: string | null
|
company_submission_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -5734,6 +5750,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
action_type?: string | null
|
action_type?: string | null
|
||||||
|
approved_at?: string | null
|
||||||
approved_entity_id?: string | null
|
approved_entity_id?: string | null
|
||||||
company_submission_id?: string | null
|
company_submission_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -5760,6 +5777,13 @@ export type Database = {
|
|||||||
referencedRelation: "company_submissions"
|
referencedRelation: "company_submissions"
|
||||||
referencedColumns: ["id"]
|
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"
|
foreignKeyName: "submission_items_depends_on_fkey"
|
||||||
columns: ["depends_on"]
|
columns: ["depends_on"]
|
||||||
@@ -5931,6 +5955,13 @@ export type Database = {
|
|||||||
test_session_id?: string | null
|
test_session_id?: string | null
|
||||||
}
|
}
|
||||||
Relationships: [
|
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"
|
foreignKeyName: "test_data_registry_submission_item_id_fkey"
|
||||||
columns: ["submission_item_id"]
|
columns: ["submission_item_id"]
|
||||||
@@ -6306,6 +6337,76 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
data_retention_stats: {
|
||||||
Row: {
|
Row: {
|
||||||
last_30_days: number | null
|
last_30_days: number | null
|
||||||
@@ -6628,17 +6729,19 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
analyze_data_completeness: {
|
analyze_data_completeness:
|
||||||
Args: {
|
| {
|
||||||
p_entity_type?: string
|
Args: {
|
||||||
p_limit?: number
|
p_entity_type?: string
|
||||||
p_max_score?: number
|
p_limit?: number
|
||||||
p_min_score?: number
|
p_max_score?: number
|
||||||
p_missing_category?: string
|
p_min_score?: number
|
||||||
p_offset?: number
|
p_missing_category?: string
|
||||||
}
|
p_offset?: number
|
||||||
Returns: Json
|
}
|
||||||
}
|
Returns: Json
|
||||||
|
}
|
||||||
|
| { Args: never; Returns: Json }
|
||||||
anonymize_user_submissions: {
|
anonymize_user_submissions: {
|
||||||
Args: { target_user_id: string }
|
Args: { target_user_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
@@ -6816,6 +6919,7 @@ export type Database = {
|
|||||||
Returns: string
|
Returns: string
|
||||||
}
|
}
|
||||||
extract_cf_image_id: { Args: { url: string }; Returns: string }
|
extract_cf_image_id: { Args: { url: string }; Returns: string }
|
||||||
|
filter_jsonb_array_nulls: { Args: { arr: Json }; Returns: Json }
|
||||||
generate_deletion_confirmation_code: { Args: never; Returns: string }
|
generate_deletion_confirmation_code: { Args: never; Returns: string }
|
||||||
generate_incident_number: { Args: never; Returns: string }
|
generate_incident_number: { Args: never; Returns: string }
|
||||||
generate_notification_idempotency_key: {
|
generate_notification_idempotency_key: {
|
||||||
@@ -6828,6 +6932,40 @@ export type Database = {
|
|||||||
Returns: string
|
Returns: string
|
||||||
}
|
}
|
||||||
generate_ticket_number: { Args: never; 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_auth0_sub_from_jwt: { Args: never; Returns: string }
|
||||||
get_contributor_leaderboard: {
|
get_contributor_leaderboard: {
|
||||||
Args: { limit_count?: number; time_period?: string }
|
Args: { limit_count?: number; time_period?: string }
|
||||||
@@ -6853,6 +6991,16 @@ export type Database = {
|
|||||||
Args: { _profile_user_id: string; _viewer_id?: string }
|
Args: { _profile_user_id: string; _viewer_id?: string }
|
||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
|
get_maintenance_tables: {
|
||||||
|
Args: never
|
||||||
|
Returns: {
|
||||||
|
indexes_size: string
|
||||||
|
row_count: number
|
||||||
|
table_name: string
|
||||||
|
table_size: string
|
||||||
|
total_size: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
get_my_sessions: {
|
get_my_sessions: {
|
||||||
Args: never
|
Args: never
|
||||||
Returns: {
|
Returns: {
|
||||||
@@ -7053,13 +7201,13 @@ export type Database = {
|
|||||||
monitor_slow_approvals: { Args: never; Returns: undefined }
|
monitor_slow_approvals: { Args: never; Returns: undefined }
|
||||||
process_approval_transaction: {
|
process_approval_transaction: {
|
||||||
Args: {
|
Args: {
|
||||||
|
p_approval_mode?: string
|
||||||
|
p_idempotency_key?: string
|
||||||
p_item_ids: string[]
|
p_item_ids: string[]
|
||||||
p_moderator_id: string
|
p_moderator_id: string
|
||||||
p_parent_span_id?: string
|
|
||||||
p_request_id?: string
|
p_request_id?: string
|
||||||
p_submission_id: string
|
p_submission_id: string
|
||||||
p_submitter_id: string
|
p_submitter_id: string
|
||||||
p_trace_id?: string
|
|
||||||
}
|
}
|
||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
@@ -7086,6 +7234,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
|
refresh_approval_history: { Args: never; Returns: undefined }
|
||||||
release_expired_locks: { Args: never; Returns: number }
|
release_expired_locks: { Args: never; Returns: number }
|
||||||
release_submission_lock: {
|
release_submission_lock: {
|
||||||
Args: { moderator_id: string; submission_id: string }
|
Args: { moderator_id: string; submission_id: string }
|
||||||
@@ -7115,6 +7264,7 @@ export type Database = {
|
|||||||
Returns: string
|
Returns: string
|
||||||
}
|
}
|
||||||
run_all_cleanup_jobs: { Args: never; Returns: Json }
|
run_all_cleanup_jobs: { Args: never; Returns: Json }
|
||||||
|
run_analyze_table: { Args: { table_name: string }; Returns: Json }
|
||||||
run_data_retention_cleanup: { Args: never; Returns: Json }
|
run_data_retention_cleanup: { Args: never; Returns: Json }
|
||||||
run_pipeline_monitoring: {
|
run_pipeline_monitoring: {
|
||||||
Args: never
|
Args: never
|
||||||
@@ -7124,6 +7274,7 @@ export type Database = {
|
|||||||
status: string
|
status: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
run_reindex_table: { Args: { table_name: string }; Returns: Json }
|
||||||
run_system_maintenance: {
|
run_system_maintenance: {
|
||||||
Args: never
|
Args: never
|
||||||
Returns: {
|
Returns: {
|
||||||
@@ -7132,6 +7283,7 @@ export type Database = {
|
|||||||
task: string
|
task: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
run_vacuum_table: { Args: { table_name: string }; Returns: Json }
|
||||||
set_config_value: {
|
set_config_value: {
|
||||||
Args: {
|
Args: {
|
||||||
is_local?: boolean
|
is_local?: boolean
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function getCurrentDateLocal(): string {
|
|||||||
*/
|
*/
|
||||||
export function formatDateDisplay(
|
export function formatDateDisplay(
|
||||||
dateString: string | null | undefined,
|
dateString: string | null | undefined,
|
||||||
precision: 'day' | 'month' | 'year' = 'day'
|
precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate' = 'exact'
|
||||||
): string {
|
): string {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
|
|
||||||
@@ -83,7 +83,13 @@ export function formatDateDisplay(
|
|||||||
return date.getFullYear().toString();
|
return date.getFullYear().toString();
|
||||||
case 'month':
|
case 'month':
|
||||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||||
case 'day':
|
case 'decade':
|
||||||
|
return `${Math.floor(date.getFullYear() / 10) * 10}s`;
|
||||||
|
case 'century':
|
||||||
|
return `${Math.ceil(date.getFullYear() / 100)}th century`;
|
||||||
|
case 'approximate':
|
||||||
|
return `circa ${date.getFullYear()}`;
|
||||||
|
case 'exact':
|
||||||
default:
|
default:
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -182,7 +188,7 @@ export function parseDateForDisplay(date: string | Date): Date {
|
|||||||
*/
|
*/
|
||||||
export function toDateWithPrecision(
|
export function toDateWithPrecision(
|
||||||
date: Date,
|
date: Date,
|
||||||
precision: 'day' | 'month' | 'year'
|
precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate'
|
||||||
): string {
|
): string {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = date.getMonth() + 1;
|
const month = date.getMonth() + 1;
|
||||||
@@ -193,7 +199,13 @@ export function toDateWithPrecision(
|
|||||||
return `${year}-01-01`;
|
return `${year}-01-01`;
|
||||||
case 'month':
|
case 'month':
|
||||||
return `${year}-${String(month).padStart(2, '0')}-01`;
|
return `${year}-${String(month).padStart(2, '0')}-01`;
|
||||||
case 'day':
|
case 'decade':
|
||||||
|
return `${Math.floor(year / 10) * 10}-01-01`;
|
||||||
|
case 'century':
|
||||||
|
return `${Math.floor((year - 1) / 100) * 100 + 1}-01-01`;
|
||||||
|
case 'approximate':
|
||||||
|
return `${year}-01-01`;
|
||||||
|
case 'exact':
|
||||||
default:
|
default:
|
||||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
171
src/lib/enhancedValidation.ts
Normal file
171
src/lib/enhancedValidation.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced Validation Messages
|
||||||
|
* Provides contextual, helpful error messages with examples
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const validationMessages = {
|
||||||
|
slug: {
|
||||||
|
format: 'Slug must contain only lowercase letters, numbers, and hyphens. Example: "steel-vengeance" or "millennium-force"',
|
||||||
|
required: 'Slug is required. It will be used in the URL. Example: "fury-325"',
|
||||||
|
duplicate: 'This slug is already in use. Try adding a location or number: "thunder-run-kentucky"',
|
||||||
|
},
|
||||||
|
|
||||||
|
url: {
|
||||||
|
format: 'Must be a valid URL starting with http:// or https://. Example: "https://www.cedarpoint.com"',
|
||||||
|
protocol: 'URL must start with http:// or https://. Add the protocol to your URL.',
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
format: 'Must be a valid email address. Example: "contact@park.com"',
|
||||||
|
},
|
||||||
|
|
||||||
|
phone: {
|
||||||
|
format: 'Enter phone number in any format. Examples: "+1-419-555-0123" or "(419) 555-0123"',
|
||||||
|
maxLength: (max: number) => `Phone number must be less than ${max} characters`,
|
||||||
|
},
|
||||||
|
|
||||||
|
dates: {
|
||||||
|
future: 'Opening date cannot be in the future. Use today or an earlier date.',
|
||||||
|
closingBeforeOpening: 'Closing date must be after opening date. Check both dates for accuracy.',
|
||||||
|
invalidFormat: 'Invalid date format. Use the date picker or enter in YYYY-MM-DD format.',
|
||||||
|
precision: 'Select how precise this date is (exact, month, year, etc.)',
|
||||||
|
},
|
||||||
|
|
||||||
|
numbers: {
|
||||||
|
heightRequirement: 'Height must be in centimeters, between 0-300. Example: "122" for 122cm (48 inches)',
|
||||||
|
speed: 'Speed must be in km/h, between 0-500. Example: "193" for 193 km/h (120 mph)',
|
||||||
|
length: 'Length must be in meters. Example: "1981" for 1,981 meters (6,500 feet)',
|
||||||
|
height: 'Height must be in meters. Example: "94" for 94 meters (310 feet)',
|
||||||
|
gForce: 'G-force must be between -10 and 10. Example: "4.5" for 4.5 positive Gs',
|
||||||
|
inversions: 'Number of inversions (upside-down elements). Example: "7"',
|
||||||
|
capacity: 'Capacity per hour must be between 1-99,999. Example: "1200" for 1,200 riders/hour',
|
||||||
|
duration: 'Duration in seconds. Example: "180" for 3 minutes',
|
||||||
|
positive: 'Value must be a positive number',
|
||||||
|
range: (min: number, max: number) => `Value must be between ${min} and ${max}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
text: {
|
||||||
|
required: 'This field is required',
|
||||||
|
maxLength: (max: number, current?: number) =>
|
||||||
|
current ? `${current}/${max} characters. Please shorten by ${current - max} characters.` : `Maximum ${max} characters`,
|
||||||
|
minLength: (min: number) => `Must be at least ${min} characters`,
|
||||||
|
noHtml: 'HTML tags are not allowed. Use plain text only.',
|
||||||
|
trimmed: 'Extra spaces at the beginning or end will be removed',
|
||||||
|
},
|
||||||
|
|
||||||
|
park: {
|
||||||
|
nameRequired: 'Park name is required. Example: "Cedar Point" or "Six Flags Magic Mountain"',
|
||||||
|
typeRequired: 'Select a park type (theme park, amusement park, water park, etc.)',
|
||||||
|
statusRequired: 'Select the current status (operating, closed, under construction, etc.)',
|
||||||
|
locationRequired: 'Location is required. Use the search to find or add a location.',
|
||||||
|
operatorHelp: 'The company that operates the park (e.g., Cedar Fair, Six Flags)',
|
||||||
|
ownerHelp: 'The company that owns the property (often same as operator)',
|
||||||
|
},
|
||||||
|
|
||||||
|
ride: {
|
||||||
|
nameRequired: 'Ride name is required. Example: "Steel Vengeance" or "Maverick"',
|
||||||
|
categoryRequired: 'Select a ride category (roller coaster, flat ride, water ride, etc.)',
|
||||||
|
parkRequired: 'Park is required. Select or create the park where this ride is located.',
|
||||||
|
manufacturerHelp: 'Company that manufactured the ride (e.g., RMC, Intamin, B&M)',
|
||||||
|
designerHelp: 'Company that designed the ride (if different from manufacturer)',
|
||||||
|
trackMaterial: 'Materials used for the track. Common: Steel, Wood, Hybrid (RMC IBox)',
|
||||||
|
supportMaterial: 'Materials used for support structure. Common: Steel, Wood',
|
||||||
|
propulsionMethod: 'How the ride is propelled. Common: LSM Launch, Chain Lift, Hydraulic Launch',
|
||||||
|
},
|
||||||
|
|
||||||
|
company: {
|
||||||
|
nameRequired: 'Company name is required. Example: "Rocky Mountain Construction"',
|
||||||
|
typeRequired: 'Select company type (manufacturer, designer, operator, property owner)',
|
||||||
|
countryHelp: 'Country where the company is headquartered',
|
||||||
|
},
|
||||||
|
|
||||||
|
units: {
|
||||||
|
metricOnly: 'All measurements must be in metric units (m, km, cm, kg, km/h, etc.)',
|
||||||
|
metricExamples: 'Use metric: m (meters), km/h (speed), cm (centimeters), kg (weight)',
|
||||||
|
imperialNote: 'The system will automatically convert to imperial for users who prefer it',
|
||||||
|
temperature: 'Temperature must be in Celsius. Example: "25" for 25°C (77°F)',
|
||||||
|
},
|
||||||
|
|
||||||
|
submission: {
|
||||||
|
sourceUrl: 'Where did you find this information? Helps moderators verify accuracy. Example: manufacturer website, news article, park map',
|
||||||
|
notes: 'Add context for moderators. Example: "Confirmed via park press release" or "Specifications approximate"',
|
||||||
|
notesMaxLength: 'Submission notes must be less than 1000 characters',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common validation helpers
|
||||||
|
*/
|
||||||
|
export const validationHelpers = {
|
||||||
|
/**
|
||||||
|
* Check if a URL has proper protocol
|
||||||
|
*/
|
||||||
|
hasProtocol: (url: string): boolean => {
|
||||||
|
return url.startsWith('http://') || url.startsWith('https://');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest adding protocol to URL
|
||||||
|
*/
|
||||||
|
suggestProtocol: (url: string): string => {
|
||||||
|
if (!url) return '';
|
||||||
|
if (validationHelpers.hasProtocol(url)) return url;
|
||||||
|
return `https://${url}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a slug from a name
|
||||||
|
*/
|
||||||
|
formatSlug: (name: string): string => {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is in the future
|
||||||
|
*/
|
||||||
|
isFutureDate: (date: string | Date): boolean => {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d > new Date();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format character count display
|
||||||
|
*/
|
||||||
|
formatCharCount: (current: number, max: number): string => {
|
||||||
|
const remaining = max - current;
|
||||||
|
if (remaining < 0) {
|
||||||
|
return `${current}/${max} (${Math.abs(remaining)} over limit)`;
|
||||||
|
}
|
||||||
|
if (remaining < 50) {
|
||||||
|
return `${current}/${max} (${remaining} remaining)`;
|
||||||
|
}
|
||||||
|
return `${current}/${max}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field-specific validation hints for FormDescription
|
||||||
|
*/
|
||||||
|
export const fieldHints = {
|
||||||
|
slug: 'URL-friendly identifier using lowercase letters, numbers, and hyphens only',
|
||||||
|
websiteUrl: 'Official website URL (must start with https:// or http://)',
|
||||||
|
email: 'Contact email for the park or ride operator',
|
||||||
|
phone: 'Contact phone number in any format',
|
||||||
|
heightRequirement: 'Minimum height in centimeters (metric). Will be converted for display.',
|
||||||
|
ageRequirement: 'Minimum age requirement in years',
|
||||||
|
capacity: 'Theoretical maximum riders per hour under optimal conditions',
|
||||||
|
duration: 'Typical ride duration in seconds from dispatch to return',
|
||||||
|
speed: 'Maximum speed in km/h (metric). Will be converted for display.',
|
||||||
|
height: 'Maximum height in meters (metric). Will be converted for display.',
|
||||||
|
length: 'Track/route length in meters (metric). Will be converted for display.',
|
||||||
|
inversions: 'Total number of elements where riders go upside down (≥90 degrees)',
|
||||||
|
gForce: 'Maximum positive or negative G-forces experienced',
|
||||||
|
sourceUrl: 'Reference link to verify this information (Wikipedia, official site, news article, etc.)',
|
||||||
|
submissionNotes: 'Help moderators understand your submission (how you verified the info, any uncertainties, etc.)',
|
||||||
|
};
|
||||||
@@ -3708,7 +3708,7 @@ export async function submitTimelineEventUpdate(
|
|||||||
entity_id: originalEvent.entity_id,
|
entity_id: originalEvent.entity_id,
|
||||||
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
|
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
|
||||||
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
|
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
|
||||||
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
|
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'exact',
|
||||||
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
|
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
|
||||||
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
|
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
|
||||||
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
|
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ export const parkValidationSchema = z.object({
|
|||||||
const date = new Date(val);
|
const date = new Date(val);
|
||||||
return date <= new Date();
|
return date <= new Date();
|
||||||
}, 'Opening date cannot be in the future'),
|
}, 'Opening date cannot be in the future'),
|
||||||
opening_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||||
closing_date: z.string().nullish().transform(val => val ?? undefined),
|
closing_date: z.string().nullish().transform(val => val ?? undefined),
|
||||||
closing_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||||
location_id: z.string().uuid().optional().nullable(),
|
location_id: z.string().uuid().optional().nullable(),
|
||||||
location: z.object({
|
location: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -139,9 +139,9 @@ export const rideValidationSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
opening_date: z.string().nullish().transform(val => val ?? undefined),
|
opening_date: z.string().nullish().transform(val => val ?? undefined),
|
||||||
opening_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
opening_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||||
closing_date: z.string().nullish().transform(val => val ?? undefined),
|
closing_date: z.string().nullish().transform(val => val ?? undefined),
|
||||||
closing_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
closing_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||||
height_requirement: z.preprocess(
|
height_requirement: z.preprocess(
|
||||||
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
|
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
|
||||||
z.number().int().min(0, 'Height requirement must be positive').max(300, 'Height requirement must be less than 300cm').optional()
|
z.number().int().min(0, 'Height requirement must be positive').max(300, 'Height requirement must be less than 300cm').optional()
|
||||||
@@ -322,7 +322,7 @@ export const companyValidationSchema = z.object({
|
|||||||
description: z.string().trim().max(2000, 'Description must be less than 2000 characters').nullish().transform(val => val ?? undefined),
|
description: z.string().trim().max(2000, 'Description must be less than 2000 characters').nullish().transform(val => val ?? undefined),
|
||||||
person_type: z.enum(['company', 'individual', 'firm', 'organization']),
|
person_type: z.enum(['company', 'individual', 'firm', 'organization']),
|
||||||
founded_date: z.string().nullish().transform(val => val ?? undefined),
|
founded_date: z.string().nullish().transform(val => val ?? undefined),
|
||||||
founded_date_precision: z.enum(['day', 'month', 'year']).nullable().optional(),
|
founded_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).nullable().optional(),
|
||||||
founded_year: z.preprocess(
|
founded_year: z.preprocess(
|
||||||
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
|
(val) => val === '' || val === null || val === undefined ? undefined : Number(val),
|
||||||
z.number().int().min(1800, 'Founded year must be after 1800').max(currentYear, `Founded year cannot be after ${currentYear}`).optional()
|
z.number().int().min(1800, 'Founded year must be after 1800').max(currentYear, `Founded year cannot be after ${currentYear}`).optional()
|
||||||
@@ -401,7 +401,7 @@ export const milestoneValidationSchema = z.object({
|
|||||||
fiveYearsFromNow.setFullYear(fiveYearsFromNow.getFullYear() + 5);
|
fiveYearsFromNow.setFullYear(fiveYearsFromNow.getFullYear() + 5);
|
||||||
return date <= fiveYearsFromNow;
|
return date <= fiveYearsFromNow;
|
||||||
}, 'Event date cannot be more than 5 years in the future'),
|
}, 'Event date cannot be more than 5 years in the future'),
|
||||||
event_date_precision: z.enum(['day', 'month', 'year']).optional().default('day'),
|
event_date_precision: z.enum(['exact', 'month', 'year', 'decade', 'century', 'approximate']).optional().default('exact'),
|
||||||
entity_type: z.string().min(1, 'Entity type is required'),
|
entity_type: z.string().min(1, 'Entity type is required'),
|
||||||
entity_id: z.string().uuid('Invalid entity ID'),
|
entity_id: z.string().uuid('Invalid entity ID'),
|
||||||
is_public: z.boolean().optional(),
|
is_public: z.boolean().optional(),
|
||||||
|
|||||||
65
src/lib/formToasts.ts
Normal file
65
src/lib/formToasts.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized toast notifications for form submissions
|
||||||
|
* Provides consistent success/error feedback across all forms
|
||||||
|
*/
|
||||||
|
export const formToasts = {
|
||||||
|
success: {
|
||||||
|
create: (entityType: string, entityName?: string) => {
|
||||||
|
toast({
|
||||||
|
title: '✓ Submission Created',
|
||||||
|
description: entityName
|
||||||
|
? `${entityName} has been submitted for review.`
|
||||||
|
: `${entityType} has been submitted for review.`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (entityType: string, entityName?: string) => {
|
||||||
|
toast({
|
||||||
|
title: '✓ Update Submitted',
|
||||||
|
description: entityName
|
||||||
|
? `Changes to ${entityName} have been submitted for review.`
|
||||||
|
: `${entityType} update has been submitted for review.`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
moderatorApproval: (entityType: string, entityName?: string) => {
|
||||||
|
toast({
|
||||||
|
title: '✓ Published Successfully',
|
||||||
|
description: entityName
|
||||||
|
? `${entityName} is now live on the site.`
|
||||||
|
: `${entityType} is now live on the site.`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
error: {
|
||||||
|
validation: (fieldCount: number) => {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Failed',
|
||||||
|
description: `Please fix ${fieldCount} error${fieldCount > 1 ? 's' : ''} before submitting.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
network: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Connection Error',
|
||||||
|
description: 'Unable to submit. Please check your connection and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generic: (error: string) => {
|
||||||
|
toast({
|
||||||
|
title: 'Submission Failed',
|
||||||
|
description: error,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
390
src/lib/glossary.ts
Normal file
390
src/lib/glossary.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Theme Park Terminology Glossary
|
||||||
|
* Comprehensive definitions for technical terms used in forms
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GlossaryTerm {
|
||||||
|
term: string;
|
||||||
|
category: 'manufacturer' | 'technology' | 'element' | 'component' | 'measurement' | 'type' | 'material';
|
||||||
|
definition: string;
|
||||||
|
example?: string;
|
||||||
|
relatedTerms?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const glossary: Record<string, GlossaryTerm> = {
|
||||||
|
// Manufacturers
|
||||||
|
'rmc': {
|
||||||
|
term: 'RMC',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Rocky Mountain Construction - Manufacturer known for hybrid coasters with steel IBox track on wooden structures',
|
||||||
|
example: 'Steel Vengeance at Cedar Point',
|
||||||
|
relatedTerms: ['ibox-track', 'hybrid-coaster'],
|
||||||
|
},
|
||||||
|
'intamin': {
|
||||||
|
term: 'Intamin',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Swiss manufacturer known for record-breaking coasters and innovative launch systems',
|
||||||
|
example: 'Millennium Force, Top Thrill Dragster',
|
||||||
|
relatedTerms: ['hydraulic-launch', 'lsm'],
|
||||||
|
},
|
||||||
|
'b&m': {
|
||||||
|
term: 'B&M',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Bolliger & Mabillard - Swiss manufacturer known for smooth, reliable coasters',
|
||||||
|
example: 'Fury 325, Banshee, GateKeeper',
|
||||||
|
relatedTerms: ['inverted', 'wing-coaster', 'dive-coaster'],
|
||||||
|
},
|
||||||
|
'vekoma': {
|
||||||
|
term: 'Vekoma',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'Dutch manufacturer with wide range from family coasters to intense thrill rides',
|
||||||
|
example: 'Space Mountain (Disney), Thunderbird (PowerPark)',
|
||||||
|
},
|
||||||
|
'gerstlauer': {
|
||||||
|
term: 'Gerstlauer',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'German manufacturer known for compact, intense coasters with vertical lifts',
|
||||||
|
example: 'Takabisha (steepest drop), Karacho',
|
||||||
|
relatedTerms: ['euro-fighter'],
|
||||||
|
},
|
||||||
|
's&s': {
|
||||||
|
term: 'S&S',
|
||||||
|
category: 'manufacturer',
|
||||||
|
definition: 'S&S Worldwide - American manufacturer of compressed-air launch coasters and thrill rides',
|
||||||
|
example: 'Hypersonic XLC, Screamin\' Swing',
|
||||||
|
relatedTerms: ['compressed-air-launch'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Launch/Propulsion Systems
|
||||||
|
'lsm': {
|
||||||
|
term: 'LSM Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Linear Synchronous Motor - Uses electromagnetic propulsion to smoothly accelerate trains',
|
||||||
|
example: 'Maverick, Taron, Velocicoaster',
|
||||||
|
relatedTerms: ['lim', 'magnetic-launch'],
|
||||||
|
},
|
||||||
|
'lim': {
|
||||||
|
term: 'LIM Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Linear Induction Motor - Earlier electromagnetic launch technology, less efficient than LSM',
|
||||||
|
example: 'Flight of Fear, Rock \'n\' Roller Coaster',
|
||||||
|
relatedTerms: ['lsm'],
|
||||||
|
},
|
||||||
|
'hydraulic-launch': {
|
||||||
|
term: 'Hydraulic Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Uses hydraulic winch system to rapidly accelerate train, capable of extreme speeds',
|
||||||
|
example: 'Top Thrill Dragster, Kingda Ka (fastest launches)',
|
||||||
|
relatedTerms: ['intamin'],
|
||||||
|
},
|
||||||
|
'chain-lift': {
|
||||||
|
term: 'Chain Lift',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Traditional lift system using chain and anti-rollback dogs',
|
||||||
|
example: 'Most traditional wooden and steel coasters',
|
||||||
|
},
|
||||||
|
'cable-lift': {
|
||||||
|
term: 'Cable Lift',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Uses steel cable for faster lift speeds than chain',
|
||||||
|
example: 'Millennium Force (first major use)',
|
||||||
|
},
|
||||||
|
'compressed-air-launch': {
|
||||||
|
term: 'Compressed Air Launch',
|
||||||
|
category: 'technology',
|
||||||
|
definition: 'Uses compressed air to launch train, very powerful acceleration',
|
||||||
|
example: 'Hypersonic XLC, Do-Dodonpa',
|
||||||
|
relatedTerms: ['s&s'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Coaster Types
|
||||||
|
'inverted': {
|
||||||
|
term: 'Inverted Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Train runs below the track with feet dangling, track above riders',
|
||||||
|
example: 'Banshee, Montu, Raptor',
|
||||||
|
relatedTerms: ['b&m'],
|
||||||
|
},
|
||||||
|
'wing-coaster': {
|
||||||
|
term: 'Wing Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Seats extend to sides of track with nothing above or below riders',
|
||||||
|
example: 'GateKeeper, The Swarm, X-Flight',
|
||||||
|
relatedTerms: ['b&m'],
|
||||||
|
},
|
||||||
|
'dive-coaster': {
|
||||||
|
term: 'Dive Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Features wide trains and vertical/near-vertical first drop, often with holding brake',
|
||||||
|
example: 'Valravn, SheiKra, Griffon',
|
||||||
|
relatedTerms: ['b&m'],
|
||||||
|
},
|
||||||
|
'flying-coaster': {
|
||||||
|
term: 'Flying Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Riders positioned face-down in flying position',
|
||||||
|
example: 'Tatsu, Manta, Flying Dinosaur',
|
||||||
|
relatedTerms: ['b&m', 'vekoma'],
|
||||||
|
},
|
||||||
|
'hyper-coaster': {
|
||||||
|
term: 'Hyper Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Coaster between 200-299 feet tall, focused on airtime',
|
||||||
|
example: 'Diamondback, Nitro, Apollo\'s Chariot',
|
||||||
|
relatedTerms: ['giga-coaster', 'airtime'],
|
||||||
|
},
|
||||||
|
'giga-coaster': {
|
||||||
|
term: 'Giga Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Coaster between 300-399 feet tall',
|
||||||
|
example: 'Millennium Force, Fury 325, Leviathan',
|
||||||
|
relatedTerms: ['hyper-coaster', 'strata-coaster'],
|
||||||
|
},
|
||||||
|
'strata-coaster': {
|
||||||
|
term: 'Strata Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Coaster 400+ feet tall',
|
||||||
|
example: 'Top Thrill Dragster, Kingda Ka',
|
||||||
|
relatedTerms: ['giga-coaster'],
|
||||||
|
},
|
||||||
|
'hybrid-coaster': {
|
||||||
|
term: 'Hybrid Coaster',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Steel track on wooden support structure',
|
||||||
|
example: 'Steel Vengeance, Twisted Colossus',
|
||||||
|
relatedTerms: ['rmc', 'ibox-track'],
|
||||||
|
},
|
||||||
|
'euro-fighter': {
|
||||||
|
term: 'Euro-Fighter',
|
||||||
|
category: 'type',
|
||||||
|
definition: 'Compact Gerstlauer coaster with vertical lift and beyond-vertical drop',
|
||||||
|
example: 'Takabisha, Saw: The Ride',
|
||||||
|
relatedTerms: ['gerstlauer'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Track Materials
|
||||||
|
'ibox-track': {
|
||||||
|
term: 'IBox Track',
|
||||||
|
category: 'material',
|
||||||
|
definition: 'RMC\'s steel box-beam track system used on hybrid coasters, allows extreme elements',
|
||||||
|
example: 'Steel Vengeance, Iron Rattler',
|
||||||
|
relatedTerms: ['rmc', 'hybrid-coaster', 'topper-track'],
|
||||||
|
},
|
||||||
|
'topper-track': {
|
||||||
|
term: 'Topper Track',
|
||||||
|
category: 'material',
|
||||||
|
definition: 'RMC\'s steel plate topper on wooden track for smoother wooden coaster experience',
|
||||||
|
example: 'Outlaw Run, Lightning Rod',
|
||||||
|
relatedTerms: ['rmc', 'ibox-track'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Restraint Types
|
||||||
|
'otsr': {
|
||||||
|
term: 'OTSR',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Over-The-Shoulder Restraint - Safety harness that goes over shoulders and locks at waist',
|
||||||
|
example: 'Used on most inverting coasters',
|
||||||
|
relatedTerms: ['vest-restraint', 'lap-bar'],
|
||||||
|
},
|
||||||
|
'lap-bar': {
|
||||||
|
term: 'Lap Bar',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Restraint that only crosses the lap/thighs, offers more freedom',
|
||||||
|
example: 'Millennium Force, most airtime-focused rides',
|
||||||
|
relatedTerms: ['otsr', 't-bar'],
|
||||||
|
},
|
||||||
|
't-bar': {
|
||||||
|
term: 'T-Bar',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'T-shaped lap bar restraint, common on Intamin hyper coasters',
|
||||||
|
example: 'Intimidator 305, Skyrush',
|
||||||
|
relatedTerms: ['lap-bar'],
|
||||||
|
},
|
||||||
|
'vest-restraint': {
|
||||||
|
term: 'Vest Restraint',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Soft vest-style over-shoulder restraint, more comfortable than traditional OTSR',
|
||||||
|
example: 'GateKeeper, Valravn (B&M)',
|
||||||
|
relatedTerms: ['otsr'],
|
||||||
|
},
|
||||||
|
'shin-bar': {
|
||||||
|
term: 'Shin Bar',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Additional restraint that holds shins in place, used on some intense rides',
|
||||||
|
example: 'Flying coasters, some Vekoma rides',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
'airtime': {
|
||||||
|
term: 'Airtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Negative G-forces that create sensation of floating or being lifted from seat',
|
||||||
|
example: 'Camelback hills, speed hills',
|
||||||
|
relatedTerms: ['ejector-airtime', 'floater-airtime', 'hangtime'],
|
||||||
|
},
|
||||||
|
'ejector-airtime': {
|
||||||
|
term: 'Ejector Airtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Strong negative Gs that forcefully lift riders from seats',
|
||||||
|
example: 'El Toro, Skyrush airtime hills',
|
||||||
|
relatedTerms: ['airtime'],
|
||||||
|
},
|
||||||
|
'floater-airtime': {
|
||||||
|
term: 'Floater Airtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Gentle negative Gs that create sustained floating sensation',
|
||||||
|
example: 'B&M hyper coasters',
|
||||||
|
relatedTerms: ['airtime'],
|
||||||
|
},
|
||||||
|
'hangtime': {
|
||||||
|
term: 'Hangtime',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Suspension in mid-air during inversion, typically at apex of element',
|
||||||
|
example: 'Zero-g rolls, inversions on dive coasters',
|
||||||
|
relatedTerms: ['airtime', 'inversion'],
|
||||||
|
},
|
||||||
|
'inversion': {
|
||||||
|
term: 'Inversion',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Element where riders are turned upside down (≥90 degrees from upright)',
|
||||||
|
example: 'Loops, corkscrews, barrel rolls',
|
||||||
|
relatedTerms: ['zero-g-roll', 'corkscrew', 'loop'],
|
||||||
|
},
|
||||||
|
'zero-g-roll': {
|
||||||
|
term: 'Zero-G Roll',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Heartline inversion with sustained weightlessness',
|
||||||
|
example: 'Common on Intamin and B&M coasters',
|
||||||
|
relatedTerms: ['inversion', 'hangtime'],
|
||||||
|
},
|
||||||
|
'corkscrew': {
|
||||||
|
term: 'Corkscrew',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Inversion where track twists 360 degrees while moving forward',
|
||||||
|
example: 'Classic Arrow element',
|
||||||
|
relatedTerms: ['inversion'],
|
||||||
|
},
|
||||||
|
'loop': {
|
||||||
|
term: 'Vertical Loop',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Full 360-degree vertical circle',
|
||||||
|
example: 'Classic clothoid loop shape',
|
||||||
|
relatedTerms: ['inversion'],
|
||||||
|
},
|
||||||
|
'dive-loop': {
|
||||||
|
term: 'Dive Loop',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Half loop up, half corkscrew down',
|
||||||
|
example: 'Common on B&M coasters',
|
||||||
|
relatedTerms: ['immelmann', 'inversion'],
|
||||||
|
},
|
||||||
|
'immelmann': {
|
||||||
|
term: 'Immelmann',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Half loop up, half roll out (opposite of dive loop)',
|
||||||
|
example: 'Named after WWI pilot maneuver',
|
||||||
|
relatedTerms: ['dive-loop', 'inversion'],
|
||||||
|
},
|
||||||
|
'cobra-roll': {
|
||||||
|
term: 'Cobra Roll',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Double inversion creating S-shape, reversing direction',
|
||||||
|
example: 'Common on Vekoma and B&M loopers',
|
||||||
|
relatedTerms: ['inversion'],
|
||||||
|
},
|
||||||
|
'heartline-roll': {
|
||||||
|
term: 'Heartline Roll',
|
||||||
|
category: 'element',
|
||||||
|
definition: 'Barrel roll rotating around rider\'s heartline for smooth inversion',
|
||||||
|
example: 'Maverick, many Intamin coasters',
|
||||||
|
relatedTerms: ['zero-g-roll', 'inversion'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Technical Terms
|
||||||
|
'mcbr': {
|
||||||
|
term: 'MCBR',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Mid-Course Brake Run - Safety brake zone that divides track into blocks',
|
||||||
|
example: 'Allows multiple trains to operate safely',
|
||||||
|
relatedTerms: ['block-section'],
|
||||||
|
},
|
||||||
|
'block-section': {
|
||||||
|
term: 'Block Section',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Track section that only one train can occupy at a time for safety',
|
||||||
|
example: 'Station, lift hill, brake runs',
|
||||||
|
relatedTerms: ['mcbr'],
|
||||||
|
},
|
||||||
|
'trim-brake': {
|
||||||
|
term: 'Trim Brake',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Brake that slows train slightly to control speed',
|
||||||
|
example: 'Often on hills or before elements',
|
||||||
|
},
|
||||||
|
'transfer-track': {
|
||||||
|
term: 'Transfer Track',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Movable track section for adding/removing trains from circuit',
|
||||||
|
example: 'Allows storage of extra trains',
|
||||||
|
},
|
||||||
|
'anti-rollback': {
|
||||||
|
term: 'Anti-Rollback',
|
||||||
|
category: 'component',
|
||||||
|
definition: 'Safety device preventing train from rolling backward on lift',
|
||||||
|
example: 'Creates "clicking" sound on chain lifts',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Measurements
|
||||||
|
'g-force': {
|
||||||
|
term: 'G-Force',
|
||||||
|
category: 'measurement',
|
||||||
|
definition: 'Force of gravity felt by riders. 1G = normal gravity, positive = pushed into seat, negative = lifted from seat',
|
||||||
|
example: '4.5G on intense loops, -1.5G on airtime hills',
|
||||||
|
},
|
||||||
|
'kilometers-per-hour': {
|
||||||
|
term: 'km/h',
|
||||||
|
category: 'measurement',
|
||||||
|
definition: 'Speed measurement in kilometers per hour (metric)',
|
||||||
|
example: '193 km/h = 120 mph',
|
||||||
|
},
|
||||||
|
'meters': {
|
||||||
|
term: 'Meters',
|
||||||
|
category: 'measurement',
|
||||||
|
definition: 'Length/height measurement (metric). 1 meter ≈ 3.28 feet',
|
||||||
|
example: '94 meters = 310 feet',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get glossary term by key (normalized)
|
||||||
|
*/
|
||||||
|
export function getGlossaryTerm(term: string): GlossaryTerm | undefined {
|
||||||
|
const key = term.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||||
|
return glossary[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search glossary by query
|
||||||
|
*/
|
||||||
|
export function searchGlossary(query: string): GlossaryTerm[] {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return Object.values(glossary).filter(term =>
|
||||||
|
term.term.toLowerCase().includes(lowerQuery) ||
|
||||||
|
term.definition.toLowerCase().includes(lowerQuery) ||
|
||||||
|
term.example?.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all terms in a category
|
||||||
|
*/
|
||||||
|
export function getTermsByCategory(category: GlossaryTerm['category']): GlossaryTerm[] {
|
||||||
|
return Object.values(glossary).filter(term => term.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all categories
|
||||||
|
*/
|
||||||
|
export function getAllCategories(): GlossaryTerm['category'][] {
|
||||||
|
return ['manufacturer', 'technology', 'element', 'component', 'measurement', 'type', 'material'];
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export interface QueryConfig {
|
|||||||
currentPage: number;
|
currentPage: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
sortConfig?: SortConfig;
|
sortConfig?: SortConfig;
|
||||||
|
approvalDateRange?: { from: Date | null; to: Date | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +54,7 @@ export function buildSubmissionQuery(
|
|||||||
config: QueryConfig,
|
config: QueryConfig,
|
||||||
skipModeratorFilter = false
|
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
|
// Use optimized view with pre-joined profiles and entity data
|
||||||
let query = supabase
|
let query = supabase
|
||||||
@@ -103,6 +104,20 @@ export function buildSubmissionQuery(
|
|||||||
}
|
}
|
||||||
// 'all' and 'reviews' filters don't add any conditions
|
// '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
|
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
|
||||||
// Admins see all submissions
|
// Admins see all submissions
|
||||||
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
|
// Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export const queryKeys = {
|
|||||||
admin: {
|
admin: {
|
||||||
databaseStats: () => ['admin', 'database-stats'] as const,
|
databaseStats: () => ['admin', 'database-stats'] as const,
|
||||||
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const,
|
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const,
|
||||||
|
maintenanceTables: () => ['admin', 'maintenance-tables'] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Companies queries
|
||||||
|
companies: {
|
||||||
|
detail: (slug: string) => ['companies', 'detail', slug] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Analytics queries
|
// Analytics queries
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export interface FieldChange {
|
|||||||
changeType: 'added' | 'removed' | 'modified';
|
changeType: 'added' | 'removed' | 'modified';
|
||||||
metadata?: {
|
metadata?: {
|
||||||
isCreatingNewLocation?: boolean;
|
isCreatingNewLocation?: boolean;
|
||||||
precision?: 'day' | 'month' | 'year';
|
precision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
oldPrecision?: 'day' | 'month' | 'year';
|
oldPrecision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
newPrecision?: 'day' | 'month' | 'year';
|
newPrecision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +803,7 @@ function formatEntityType(entityType: string): string {
|
|||||||
/**
|
/**
|
||||||
* Format field value for display
|
* Format field value for display
|
||||||
*/
|
*/
|
||||||
export function formatFieldValue(value: any, precision?: 'day' | 'month' | 'year'): string {
|
export function formatFieldValue(value: any, precision?: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate'): string {
|
||||||
if (value === null || value === undefined) return 'None';
|
if (value === null || value === undefined) return 'None';
|
||||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
|
||||||
@@ -817,9 +817,15 @@ export function formatFieldValue(value: any, precision?: 'day' | 'month' | 'year
|
|||||||
return date.getFullYear().toString();
|
return date.getFullYear().toString();
|
||||||
} else if (precision === 'month') {
|
} else if (precision === 'month') {
|
||||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||||
|
} else if (precision === 'decade') {
|
||||||
|
return `${Math.floor(date.getFullYear() / 10) * 10}s`;
|
||||||
|
} else if (precision === 'century') {
|
||||||
|
return `${Math.ceil(date.getFullYear() / 100)}th century`;
|
||||||
|
} else if (precision === 'approximate') {
|
||||||
|
return `circa ${date.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: full date
|
// Default: full date (exact)
|
||||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export async function fetchSystemActivities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch submission reviews (approved/rejected submissions)
|
// Fetch submission reviews (approved/rejected submissions)
|
||||||
// Note: Content is now in submission_metadata table, but entity_name is cached in view
|
// Note: Content is now in submission_metadata table - need to join and filter properly
|
||||||
const { data: submissions, error: submissionsError } = await supabase
|
const { data: submissions, error: submissionsError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -377,8 +377,9 @@ export async function fetchSystemActivities(
|
|||||||
status,
|
status,
|
||||||
reviewer_id,
|
reviewer_id,
|
||||||
reviewed_at,
|
reviewed_at,
|
||||||
submission_metadata(name)
|
submission_metadata!inner(metadata_value)
|
||||||
`)
|
`)
|
||||||
|
.eq('submission_metadata.metadata_key', 'name')
|
||||||
.not('reviewed_at', 'is', null)
|
.not('reviewed_at', 'is', null)
|
||||||
.in('status', ['approved', 'rejected', 'partially_approved'])
|
.in('status', ['approved', 'rejected', 'partially_approved'])
|
||||||
.order('reviewed_at', { ascending: false })
|
.order('reviewed_at', { ascending: false })
|
||||||
@@ -415,10 +416,10 @@ export async function fetchSystemActivities(
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const submission of submissions) {
|
for (const submission of submissions) {
|
||||||
// Get name from submission_metadata
|
// Get name from submission_metadata - extract metadata_value from the joined result
|
||||||
const metadata = submission.submission_metadata as any;
|
const metadata = submission.submission_metadata as any;
|
||||||
const entityName = Array.isArray(metadata) && metadata.length > 0
|
const entityName = Array.isArray(metadata) && metadata.length > 0
|
||||||
? metadata[0]?.name
|
? metadata[0]?.metadata_value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const submissionItem = itemsMap.get(submission.id);
|
const submissionItem = itemsMap.get(submission.id);
|
||||||
|
|||||||
@@ -257,14 +257,14 @@ export function generateRandomCompany(type: 'manufacturer' | 'operator' | 'desig
|
|||||||
// Add full founded date with precision
|
// Add full founded date with precision
|
||||||
if (shouldPopulateField(density, counter, 'medium')) {
|
if (shouldPopulateField(density, counter, 'medium')) {
|
||||||
companyData.founded_date = `${foundedYear}-01-01`;
|
companyData.founded_date = `${foundedYear}-01-01`;
|
||||||
companyData.founded_date_precision = randomItem(['year', 'month', 'day']);
|
companyData.founded_date_precision = randomItem(['year', 'month', 'exact']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add defunct date for some companies
|
// Add defunct date for some companies
|
||||||
if (shouldPopulateField(density, counter, 'low') && Math.random() > 0.85) {
|
if (shouldPopulateField(density, counter, 'low') && Math.random() > 0.85) {
|
||||||
const defunctYear = randomInt(foundedYear + 10, 2024);
|
const defunctYear = randomInt(foundedYear + 10, 2024);
|
||||||
companyData.defunct_date = `${defunctYear}-12-31`;
|
companyData.defunct_date = `${defunctYear}-12-31`;
|
||||||
companyData.defunct_date_precision = randomItem(['year', 'month', 'day']);
|
companyData.defunct_date_precision = randomItem(['year', 'month', 'exact']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add source URL
|
// Add source URL
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ export function randomDate(startYear: number, endYear: number): string {
|
|||||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function randomDatePrecision(): 'day' | 'month' | 'year' {
|
export function randomDatePrecision(): 'exact' | 'month' | 'year' {
|
||||||
return randomItem(['day', 'month', 'year']);
|
return randomItem(['exact', 'month', 'year']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -149,12 +151,7 @@ export default function DesignerDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<CompanyDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-64 bg-muted rounded-lg"></div>
|
|
||||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -181,13 +178,17 @@ export default function DesignerDetail() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
{/* Back Button and Edit Button */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EntityBreadcrumb
|
||||||
<Button variant="ghost" onClick={() => navigate('/designers')}>
|
segments={[
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
{ label: 'Designers', href: '/designers' },
|
||||||
Back to Designers
|
{ label: designer.name }
|
||||||
</Button>
|
]}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this designer")}
|
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this designer")}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
import { trackPageView } from '@/lib/viewTracking';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
@@ -159,12 +161,7 @@ export default function ManufacturerDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<CompanyDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-64 bg-muted rounded-lg"></div>
|
|
||||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,14 +188,17 @@ export default function ManufacturerDetail() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
{/* Back Button and Edit Button */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EntityBreadcrumb
|
||||||
<Button variant="ghost" onClick={() => navigate('/manufacturers')}>
|
segments={[
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
{ label: 'Manufacturers', href: '/manufacturers' },
|
||||||
<span className="md:hidden">Back</span>
|
{ label: manufacturer.name }
|
||||||
<span className="hidden md:inline">Back to Manufacturers</span>
|
]}
|
||||||
</Button>
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this manufacturer")}
|
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this manufacturer")}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
import { trackPageView } from '@/lib/viewTracking';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
@@ -188,12 +190,7 @@ export default function OperatorDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<CompanyDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-64 bg-muted rounded-lg"></div>
|
|
||||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,13 +217,17 @@ export default function OperatorDetail() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
{/* Back Button and Edit Button */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EntityBreadcrumb
|
||||||
<Button variant="ghost" onClick={() => navigate('/operators')}>
|
segments={[
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
{ label: 'Operators', href: '/operators' },
|
||||||
Back to Operators
|
{ label: operator.name }
|
||||||
</Button>
|
]}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this operator")}
|
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this operator")}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
|
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { ParkDetailSkeleton } from '@/components/loading/ParkDetailSkeleton';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
import { trackPageView } from '@/lib/viewTracking';
|
||||||
@@ -161,13 +165,7 @@ export default function ParkDetail() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="min-h-screen bg-background">
|
return <div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<ParkDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-64 bg-muted rounded-lg"></div>
|
|
||||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
|
||||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
if (!park) {
|
if (!park) {
|
||||||
@@ -191,13 +189,17 @@ export default function ParkDetail() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
{/* Back Button and Edit Button */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EntityBreadcrumb
|
||||||
<Button variant="ghost" onClick={() => navigate('/parks')}>
|
segments={[
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
{ label: 'Parks', href: '/parks' },
|
||||||
Back to Parks
|
{ label: park.name }
|
||||||
</Button>
|
]}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditParkModalOpen(true), "Sign in to edit this park")}
|
onClick={() => requireAuth(() => setIsEditParkModalOpen(true), "Sign in to edit this park")}
|
||||||
@@ -435,9 +437,19 @@ export default function ParkDetail() {
|
|||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Operator</div>
|
<div className="font-medium">Operator</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<HoverCard openDelay={300}>
|
||||||
{park.operator.name}
|
<HoverCardTrigger asChild>
|
||||||
</div>
|
<Link
|
||||||
|
to={`/operators/${park.operator.slug}`}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{park.operator.name}
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={park.operator.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
@@ -659,9 +671,9 @@ export default function ParkDetail() {
|
|||||||
park_type: park?.park_type,
|
park_type: park?.park_type,
|
||||||
status: park?.status,
|
status: park?.status,
|
||||||
opening_date: park?.opening_date ?? undefined,
|
opening_date: park?.opening_date ?? undefined,
|
||||||
opening_date_precision: (park?.opening_date_precision as 'day' | 'month' | 'year') ?? undefined,
|
opening_date_precision: (park?.opening_date_precision as 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate') ?? undefined,
|
||||||
closing_date: park?.closing_date ?? undefined,
|
closing_date: park?.closing_date ?? undefined,
|
||||||
closing_date_precision: (park?.closing_date_precision as 'day' | 'month' | 'year') ?? undefined,
|
closing_date_precision: (park?.closing_date_precision as 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate') ?? undefined,
|
||||||
location_id: park?.location?.id,
|
location_id: park?.location?.id,
|
||||||
location: park?.location ? {
|
location: park?.location ? {
|
||||||
name: park.location.name || '',
|
name: park.location.name || '',
|
||||||
|
|||||||
@@ -291,8 +291,9 @@ export default function Profile() {
|
|||||||
submission_type,
|
submission_type,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
submission_metadata(name)
|
submission_metadata!inner(metadata_value)
|
||||||
`)
|
`)
|
||||||
|
.eq('submission_metadata.metadata_key', 'name')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(10);
|
.limit(10);
|
||||||
@@ -310,10 +311,10 @@ export default function Profile() {
|
|||||||
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
|
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
|
||||||
const enriched: any = { ...sub };
|
const enriched: any = { ...sub };
|
||||||
|
|
||||||
// Get name from submission_metadata
|
// Get name from submission_metadata - extract metadata_value from the joined result
|
||||||
const metadata = sub.submission_metadata as any;
|
const metadata = sub.submission_metadata as any;
|
||||||
enriched.name = Array.isArray(metadata) && metadata.length > 0
|
enriched.name = Array.isArray(metadata) && metadata.length > 0
|
||||||
? metadata[0]?.name
|
? metadata[0]?.metadata_value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// For photo submissions, get photo count and preview
|
// For photo submissions, get photo count and preview
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
import { trackPageView } from '@/lib/viewTracking';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -188,12 +190,7 @@ export default function PropertyOwnerDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<CompanyDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-64 bg-muted rounded-lg"></div>
|
|
||||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,13 +217,17 @@ export default function PropertyOwnerDetail() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
{/* Back Button and Edit Button */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EntityBreadcrumb
|
||||||
<Button variant="ghost" onClick={() => navigate('/owners')}>
|
segments={[
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
{ label: 'Property Owners', href: '/owners' },
|
||||||
Back to Property Owners
|
{ label: owner.name }
|
||||||
</Button>
|
]}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this property owner")}
|
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this property owner")}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
|
import { CompanyPreviewCard } from '@/components/preview/CompanyPreviewCard';
|
||||||
|
import { ParkPreviewCard } from '@/components/preview/ParkPreviewCard';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { RideDetailSkeleton } from '@/components/loading/RideDetailSkeleton';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
import { getBannerUrls } from '@/lib/cloudflareImageUtils';
|
||||||
import { trackPageView } from '@/lib/viewTracking';
|
import { trackPageView } from '@/lib/viewTracking';
|
||||||
@@ -160,13 +165,7 @@ export default function RideDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<RideDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-64 bg-muted rounded-lg"></div>
|
|
||||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
|
||||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -194,18 +193,27 @@ export default function RideDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
{/* Back Button and Edit Button */}
|
{/* Breadcrumb Navigation */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EntityBreadcrumb
|
||||||
<Button
|
segments={[
|
||||||
variant="ghost"
|
{ label: 'Parks', href: '/parks' },
|
||||||
onClick={() => navigate(`/parks/${ride.park?.slug}`)}
|
{
|
||||||
>
|
label: ride.park.name,
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
href: `/parks/${ride.park.slug}`,
|
||||||
Back to {ride.park?.name}
|
showPreview: true,
|
||||||
</Button>
|
previewType: 'park',
|
||||||
|
previewSlug: ride.park.slug
|
||||||
|
},
|
||||||
|
{ label: 'Rides', href: `/parks/${ride.park.slug}#rides` },
|
||||||
|
{ label: ride.name }
|
||||||
|
]}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride")}
|
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride")}
|
||||||
@@ -255,10 +263,20 @@ export default function RideDetail() {
|
|||||||
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
|
<h1 className="text-4xl md:text-6xl font-bold text-white mb-2">
|
||||||
{ride.name}
|
{ride.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center text-white/90 text-lg">
|
<HoverCard openDelay={300}>
|
||||||
<MapPin className="w-5 h-5 mr-2" />
|
<HoverCardTrigger asChild>
|
||||||
{ride.park.name}
|
<Link
|
||||||
</div>
|
to={`/parks/${ride.park.slug}`}
|
||||||
|
className="flex items-center text-white/90 text-lg hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<MapPin className="w-5 h-5 mr-2" />
|
||||||
|
<span className="hover:underline">{ride.park.name}</span>
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||||
|
<ParkPreviewCard slug={ride.park.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<VersionIndicator
|
<VersionIndicator
|
||||||
entityType="ride"
|
entityType="ride"
|
||||||
@@ -471,9 +489,19 @@ export default function RideDetail() {
|
|||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Manufacturer</div>
|
<div className="font-medium">Manufacturer</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<HoverCard openDelay={300}>
|
||||||
{ride.manufacturer.name}
|
<HoverCardTrigger asChild>
|
||||||
</div>
|
<Link
|
||||||
|
to={`/manufacturers/${ride.manufacturer.slug}`}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{ride.manufacturer.name}
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={ride.manufacturer.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -483,9 +511,19 @@ export default function RideDetail() {
|
|||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Designer</div>
|
<div className="font-medium">Designer</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<HoverCard openDelay={300}>
|
||||||
{ride.designer.name}
|
<HoverCardTrigger asChild>
|
||||||
</div>
|
<Link
|
||||||
|
to={`/designers/${ride.designer.slug}`}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{ride.designer.name}
|
||||||
|
</Link>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" className="w-auto">
|
||||||
|
<CompanyPreviewCard slug={ride.designer.slug} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { EntityBreadcrumb } from '@/components/navigation/EntityBreadcrumb';
|
||||||
|
import { CompanyDetailSkeleton } from '@/components/loading/CompanyDetailSkeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -167,17 +169,7 @@ export default function RideModelDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="container mx-auto px-4 py-8">
|
<CompanyDetailSkeleton />
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-12 bg-muted rounded w-1/3"></div>
|
|
||||||
<div className="h-64 bg-muted rounded"></div>
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="h-48 bg-muted rounded-lg"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,12 +196,25 @@ export default function RideModelDetail() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
{/* Breadcrumb Navigation */}
|
||||||
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models`)}>
|
<EntityBreadcrumb
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
segments={[
|
||||||
Back to {manufacturer.name} Models
|
{ label: 'Manufacturers', href: '/manufacturers' },
|
||||||
</Button>
|
{
|
||||||
|
label: manufacturer.name,
|
||||||
|
href: `/manufacturers/${manufacturerSlug}`,
|
||||||
|
showPreview: true,
|
||||||
|
previewType: 'company',
|
||||||
|
previewSlug: manufacturerSlug || ''
|
||||||
|
},
|
||||||
|
{ label: 'Models', href: `/manufacturers/${manufacturerSlug}/models` },
|
||||||
|
{ label: model.name }
|
||||||
|
]}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride model")}
|
onClick={() => requireAuth(() => setIsEditModalOpen(true), "Sign in to edit this ride model")}
|
||||||
|
|||||||
136
src/pages/admin/ApprovalHistory.tsx
Normal file
136
src/pages/admin/ApprovalHistory.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Approval History Page
|
||||||
|
*
|
||||||
|
* Full-page view for compliance reporting with advanced filters,
|
||||||
|
* date range selection, and export functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ItemApprovalHistory } from '@/components/moderation/ItemApprovalHistory';
|
||||||
|
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { X, FileCheck } from 'lucide-react';
|
||||||
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import type { EntityType } from '@/types/submissions';
|
||||||
|
|
||||||
|
export default function ApprovalHistory() {
|
||||||
|
const { isModerator, loading: rolesLoading } = useUserRole();
|
||||||
|
const [fromDate, setFromDate] = useState<Date | null>(null);
|
||||||
|
const [toDate, setToDate] = useState<Date | null>(null);
|
||||||
|
const [itemType, setItemType] = useState<EntityType | 'all'>('all');
|
||||||
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
|
||||||
|
// Access control: moderators only
|
||||||
|
if (rolesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModerator()) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = fromDate || toDate || itemType !== 'all' || limit !== 100;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFromDate(null);
|
||||||
|
setToDate(null);
|
||||||
|
setItemType('all');
|
||||||
|
setLimit(100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<FileCheck className="w-8 h-8 text-primary" />
|
||||||
|
<h1 className="text-3xl font-bold">Approval History</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Complete audit trail of all approved items with exact timestamps for compliance reporting
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Date Range Filter */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<FilterDateRangePicker
|
||||||
|
label="Approval Date Range"
|
||||||
|
fromDate={fromDate}
|
||||||
|
toDate={toDate}
|
||||||
|
onFromChange={(date) => setFromDate(date || null)}
|
||||||
|
onToChange={(date) => setToDate(date || null)}
|
||||||
|
fromPlaceholder="Start Date"
|
||||||
|
toPlaceholder="End Date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Type Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="item-type">Item Type</Label>
|
||||||
|
<Select value={itemType} onValueChange={(val) => setItemType(val as EntityType | 'all')}>
|
||||||
|
<SelectTrigger id="item-type">
|
||||||
|
<SelectValue placeholder="All Types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
|
<SelectItem value="park">Parks</SelectItem>
|
||||||
|
<SelectItem value="ride">Rides</SelectItem>
|
||||||
|
<SelectItem value="manufacturer">Manufacturers</SelectItem>
|
||||||
|
<SelectItem value="designer">Designers</SelectItem>
|
||||||
|
<SelectItem value="operator">Operators</SelectItem>
|
||||||
|
<SelectItem value="ride_model">Ride Models</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Limit */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="limit">Results Limit</Label>
|
||||||
|
<Select value={limit.toString()} onValueChange={(val) => setLimit(parseInt(val))}>
|
||||||
|
<SelectTrigger id="limit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
<SelectItem value="250">250</SelectItem>
|
||||||
|
<SelectItem value="500">500</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasFilters && (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* History Table */}
|
||||||
|
<ItemApprovalHistory
|
||||||
|
dateRange={fromDate && toDate ? { from: fromDate, to: toDate } : undefined}
|
||||||
|
itemType={itemType === 'all' ? undefined : itemType}
|
||||||
|
limit={limit}
|
||||||
|
embedded={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/pages/admin/DatabaseMaintenance.tsx
Normal file
224
src/pages/admin/DatabaseMaintenance.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||||
|
import { AdminPageLayout } from '@/components/admin';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
useMaintenanceTables,
|
||||||
|
useVacuumTable,
|
||||||
|
useAnalyzeTable,
|
||||||
|
useReindexTable,
|
||||||
|
} from '@/hooks/admin/useDatabaseMaintenance';
|
||||||
|
import { Database, RefreshCw, Zap, Settings, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function DatabaseMaintenance() {
|
||||||
|
const { data: tables, isLoading, refetch } = useMaintenanceTables();
|
||||||
|
const vacuumMutation = useVacuumTable();
|
||||||
|
const analyzeMutation = useAnalyzeTable();
|
||||||
|
const reindexMutation = useReindexTable();
|
||||||
|
|
||||||
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleVacuum = (tableName: string) => {
|
||||||
|
setSelectedTable(tableName);
|
||||||
|
vacuumMutation.mutate(tableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = (tableName: string) => {
|
||||||
|
setSelectedTable(tableName);
|
||||||
|
analyzeMutation.mutate(tableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReindex = (tableName: string) => {
|
||||||
|
setSelectedTable(tableName);
|
||||||
|
reindexMutation.mutate(tableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOperationPending = (tableName: string) => {
|
||||||
|
return (
|
||||||
|
selectedTable === tableName &&
|
||||||
|
(vacuumMutation.isPending || analyzeMutation.isPending || reindexMutation.isPending)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminPageLayout
|
||||||
|
title="Database Maintenance"
|
||||||
|
description="Run vacuum, analyze, and reindex operations on database tables"
|
||||||
|
>
|
||||||
|
<Alert variant="default" className="mb-6">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Superuser Access Required</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
These operations require superuser privileges. They can help improve database
|
||||||
|
performance by reclaiming storage, updating statistics, and rebuilding indexes.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid gap-6 mb-6 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">VACUUM</CardTitle>
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Reclaims storage occupied by dead tuples and makes space available for reuse
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">ANALYZE</CardTitle>
|
||||||
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Updates statistics used by the query planner for optimal query execution plans
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">REINDEX</CardTitle>
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Rebuilds indexes to eliminate bloat and restore optimal index performance
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Database Tables</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select maintenance operations to perform on each table
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : tables && tables.length > 0 ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Table Name</TableHead>
|
||||||
|
<TableHead className="text-right">Rows</TableHead>
|
||||||
|
<TableHead className="text-right">Table Size</TableHead>
|
||||||
|
<TableHead className="text-right">Indexes Size</TableHead>
|
||||||
|
<TableHead className="text-right">Total Size</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<TableRow key={table.table_name}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<code className="text-sm">{table.table_name}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{table.row_count?.toLocaleString() || 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="secondary">{table.table_size}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="secondary">{table.indexes_size}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="outline">{table.total_size}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleVacuum(table.table_name)}
|
||||||
|
disabled={isOperationPending(table.table_name)}
|
||||||
|
>
|
||||||
|
{isOperationPending(table.table_name) &&
|
||||||
|
vacuumMutation.isPending ? (
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Vacuum'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAnalyze(table.table_name)}
|
||||||
|
disabled={isOperationPending(table.table_name)}
|
||||||
|
>
|
||||||
|
{isOperationPending(table.table_name) &&
|
||||||
|
analyzeMutation.isPending ? (
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Analyze'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReindex(table.table_name)}
|
||||||
|
disabled={isOperationPending(table.table_name)}
|
||||||
|
>
|
||||||
|
{isOperationPending(table.table_name) &&
|
||||||
|
reindexMutation.isPending ? (
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Reindex'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No tables available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AdminPageLayout>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export interface TimelineEventDatabaseRecord {
|
|||||||
entity_type: 'park' | 'ride' | 'company' | 'ride_model';
|
entity_type: 'park' | 'ride' | 'company' | 'ride_model';
|
||||||
event_type: string;
|
event_type: string;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
event_date_precision: 'day' | 'month' | 'year';
|
event_date_precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
from_value?: string | null;
|
from_value?: string | null;
|
||||||
|
|||||||
@@ -313,6 +313,14 @@ export interface SortConfig {
|
|||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval date range filter for moderation queue
|
||||||
|
*/
|
||||||
|
export interface ApprovalDateRangeFilter {
|
||||||
|
from: Date | null;
|
||||||
|
to: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading states for the moderation queue
|
* Loading states for the moderation queue
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export interface TimelineEventItemData {
|
|||||||
entity_type: 'park' | 'ride' | 'company' | 'ride_model';
|
entity_type: 'park' | 'ride' | 'company' | 'ride_model';
|
||||||
event_type: string;
|
event_type: string;
|
||||||
event_date: string; // ISO date
|
event_date: string; // ISO date
|
||||||
event_date_precision: 'day' | 'month' | 'year';
|
event_date_precision: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
from_value?: string | null;
|
from_value?: string | null;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface SubmissionItemData {
|
|||||||
rejection_reason: string | null;
|
rejection_reason: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
approved_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntityPhotoGalleryProps {
|
export interface EntityPhotoGalleryProps {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type TimelineEventType =
|
|||||||
|
|
||||||
export type EntityType = 'park' | 'ride' | 'company' | 'ride_model';
|
export type EntityType = 'park' | 'ride' | 'company' | 'ride_model';
|
||||||
|
|
||||||
export type DatePrecision = 'day' | 'month' | 'year';
|
export type DatePrecision = 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeline event stored in database after approval
|
* Timeline event stored in database after approval
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ import {
|
|||||||
} from './logger.ts';
|
} from './logger.ts';
|
||||||
import { formatEdgeError, toError } from './errorFormatter.ts';
|
import { formatEdgeError, toError } from './errorFormatter.ts';
|
||||||
import { ValidationError, logValidationError } from './typeValidation.ts';
|
import { ValidationError, logValidationError } from './typeValidation.ts';
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
|
|
||||||
export interface EdgeFunctionConfig {
|
export interface EdgeFunctionConfig {
|
||||||
name: string;
|
name: string;
|
||||||
requireAuth?: boolean;
|
requireAuth?: boolean;
|
||||||
|
requiredRoles?: string[];
|
||||||
|
useServiceRole?: boolean;
|
||||||
corsHeaders?: HeadersInit;
|
corsHeaders?: HeadersInit;
|
||||||
logRequests?: boolean;
|
logRequests?: boolean;
|
||||||
logResponses?: boolean;
|
logResponses?: boolean;
|
||||||
@@ -34,6 +37,8 @@ export interface EdgeFunctionContext {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
span: Span;
|
span: Span;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
user?: any;
|
||||||
|
supabase: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgeFunctionHandler = (
|
export type EdgeFunctionHandler = (
|
||||||
@@ -51,6 +56,8 @@ export function wrapEdgeFunction(
|
|||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
requireAuth = true,
|
requireAuth = true,
|
||||||
|
requiredRoles = [],
|
||||||
|
useServiceRole = false,
|
||||||
corsHeaders = {},
|
corsHeaders = {},
|
||||||
logRequests = true,
|
logRequests = true,
|
||||||
logResponses = true,
|
logResponses = true,
|
||||||
@@ -100,13 +107,39 @@ export function wrapEdgeFunction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
// STEP 4: Authentication (if required)
|
// STEP 4: Create Supabase client
|
||||||
|
// ====================================================================
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
|
||||||
|
let supabase;
|
||||||
|
if (useServiceRole) {
|
||||||
|
// Use service role key for backend operations
|
||||||
|
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
|
supabase = createClient(supabaseUrl, serviceRoleKey);
|
||||||
|
addSpanEvent(span, 'supabase_client_created', { type: 'service_role' });
|
||||||
|
} else if (authHeader) {
|
||||||
|
// Use anon key with user's auth header
|
||||||
|
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||||
|
supabase = createClient(supabaseUrl, anonKey, {
|
||||||
|
global: { headers: { Authorization: authHeader } }
|
||||||
|
});
|
||||||
|
addSpanEvent(span, 'supabase_client_created', { type: 'authenticated' });
|
||||||
|
} else {
|
||||||
|
// Use anon key without auth
|
||||||
|
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||||
|
supabase = createClient(supabaseUrl, anonKey);
|
||||||
|
addSpanEvent(span, 'supabase_client_created', { type: 'anonymous' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// STEP 5: Authentication (if required)
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
let userId: string | undefined;
|
let userId: string | undefined;
|
||||||
|
let user: any = undefined;
|
||||||
|
|
||||||
if (requireAuth) {
|
if (requireAuth) {
|
||||||
addSpanEvent(span, 'authentication_start');
|
addSpanEvent(span, 'authentication_start');
|
||||||
const authHeader = req.headers.get('Authorization');
|
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' });
|
addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' });
|
||||||
@@ -125,21 +158,15 @@ export function wrapEdgeFunction(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user ID from JWT (simplified - extend as needed)
|
// Get user from Supabase
|
||||||
try {
|
const { data: { user: authUser }, error: userError } = await supabase.auth.getUser();
|
||||||
// Note: In production, validate the JWT properly
|
|
||||||
const token = authHeader.replace('Bearer ', '');
|
if (userError || !authUser) {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
||||||
userId = payload.sub;
|
|
||||||
|
|
||||||
addSpanEvent(span, 'authentication_success', { userId });
|
|
||||||
span.attributes['user.id'] = userId;
|
|
||||||
} catch (error) {
|
|
||||||
addSpanEvent(span, 'authentication_failed', {
|
addSpanEvent(span, 'authentication_failed', {
|
||||||
reason: 'invalid_token',
|
reason: 'invalid_token',
|
||||||
error: formatEdgeError(error)
|
error: formatEdgeError(userError)
|
||||||
});
|
});
|
||||||
endSpan(span, 'error', error);
|
endSpan(span, 'error', userError);
|
||||||
logSpan(span);
|
logSpan(span);
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -153,10 +180,55 @@ export function wrapEdgeFunction(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user = authUser;
|
||||||
|
userId = authUser.id;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'authentication_success', { userId });
|
||||||
|
span.attributes['user.id'] = userId;
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// STEP 6: Role verification (if required)
|
||||||
|
// ====================================================================
|
||||||
|
if (requiredRoles.length > 0) {
|
||||||
|
addSpanEvent(span, 'role_check_start', { requiredRoles });
|
||||||
|
|
||||||
|
let hasRequiredRole = false;
|
||||||
|
for (const role of requiredRoles) {
|
||||||
|
const { data: hasRole } = await supabase
|
||||||
|
.rpc('has_role', { _user_id: userId, _role: role });
|
||||||
|
|
||||||
|
if (hasRole) {
|
||||||
|
hasRequiredRole = true;
|
||||||
|
addSpanEvent(span, 'role_check_success', { role });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
addSpanEvent(span, 'role_check_failed', {
|
||||||
|
userId,
|
||||||
|
requiredRoles
|
||||||
|
});
|
||||||
|
endSpan(span, 'error');
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Insufficient permissions',
|
||||||
|
requestId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
// STEP 5: Execute handler
|
// STEP 7: Execute handler
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
addSpanEvent(span, 'handler_start');
|
addSpanEvent(span, 'handler_start');
|
||||||
|
|
||||||
@@ -164,6 +236,8 @@ export function wrapEdgeFunction(
|
|||||||
requestId,
|
requestId,
|
||||||
span,
|
span,
|
||||||
userId,
|
userId,
|
||||||
|
user,
|
||||||
|
supabase,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await handler(req, context);
|
const response = await handler(req, context);
|
||||||
@@ -192,7 +266,15 @@ export function wrapEdgeFunction(
|
|||||||
logSpanToDatabase(span, requestId);
|
logSpanToDatabase(span, requestId);
|
||||||
|
|
||||||
// Clone response to add tracking headers
|
// 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, {
|
const enhancedResponse = new Response(responseBody, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||||
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { edgeLogger } from '../_shared/logger.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
|
|
||||||
interface MetricRecord {
|
interface MetricRecord {
|
||||||
metric_name: string;
|
metric_name: string;
|
||||||
@@ -9,13 +10,8 @@ interface MetricRecord {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createEdgeFunction(
|
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
|
||||||
{
|
addSpanEvent(span, 'starting_metrics_collection', { requestId });
|
||||||
name: 'collect-metrics',
|
|
||||||
requireAuth: false,
|
|
||||||
},
|
|
||||||
async (req, context, supabase) => {
|
|
||||||
edgeLogger.info('Starting metrics collection', { requestId: context.requestId });
|
|
||||||
|
|
||||||
const metrics: MetricRecord[] = [];
|
const metrics: MetricRecord[] = [];
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -32,7 +28,7 @@ export default createEdgeFunction(
|
|||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'api_error_count',
|
metric_name: 'api_error_count',
|
||||||
metric_value: errorCount as number,
|
metric_value: errorCount as number,
|
||||||
metric_category: 'performance',
|
metric_category: 'api',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,7 +45,7 @@ export default createEdgeFunction(
|
|||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'rate_limit_violations',
|
metric_name: 'rate_limit_violations',
|
||||||
metric_value: violationCount as number,
|
metric_value: violationCount as number,
|
||||||
metric_category: 'security',
|
metric_category: 'rate_limit',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,7 +61,7 @@ export default createEdgeFunction(
|
|||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'pending_submissions',
|
metric_name: 'pending_submissions',
|
||||||
metric_value: pendingCount as number,
|
metric_value: pendingCount as number,
|
||||||
metric_category: 'workflow',
|
metric_category: 'moderation',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,7 +77,7 @@ export default createEdgeFunction(
|
|||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'active_incidents',
|
metric_name: 'active_incidents',
|
||||||
metric_value: incidentCount as number,
|
metric_value: incidentCount as number,
|
||||||
metric_category: 'monitoring',
|
metric_category: 'system',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -90,14 +86,14 @@ export default createEdgeFunction(
|
|||||||
const { data: unresolvedAlerts, error: alertsError } = await supabase
|
const { data: unresolvedAlerts, error: alertsError } = await supabase
|
||||||
.from('system_alerts')
|
.from('system_alerts')
|
||||||
.select('id', { count: 'exact', head: true })
|
.select('id', { count: 'exact', head: true })
|
||||||
.eq('resolved', false);
|
.is('resolved_at', null);
|
||||||
|
|
||||||
if (!alertsError) {
|
if (!alertsError) {
|
||||||
const alertCount = unresolvedAlerts || 0;
|
const alertCount = unresolvedAlerts || 0;
|
||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'unresolved_alerts',
|
metric_name: 'unresolved_alerts',
|
||||||
metric_value: alertCount as number,
|
metric_value: alertCount as number,
|
||||||
metric_category: 'monitoring',
|
metric_category: 'system',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -116,7 +112,7 @@ export default createEdgeFunction(
|
|||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'submission_approval_rate',
|
metric_name: 'submission_approval_rate',
|
||||||
metric_value: approvalRate,
|
metric_value: approvalRate,
|
||||||
metric_category: 'workflow',
|
metric_category: 'moderation',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -140,7 +136,7 @@ export default createEdgeFunction(
|
|||||||
metrics.push({
|
metrics.push({
|
||||||
metric_name: 'avg_moderation_time',
|
metric_name: 'avg_moderation_time',
|
||||||
metric_value: avgTimeMinutes,
|
metric_value: avgTimeMinutes,
|
||||||
metric_category: 'workflow',
|
metric_category: 'moderation',
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,27 +147,30 @@ export default createEdgeFunction(
|
|||||||
.from('metric_time_series')
|
.from('metric_time_series')
|
||||||
.insert(metrics);
|
.insert(metrics);
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
edgeLogger.error('Error inserting metrics', {
|
addSpanEvent(span, 'error_inserting_metrics', { error: insertError.message });
|
||||||
error: insertError,
|
throw insertError;
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
throw insertError;
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Successfully recorded metrics', {
|
|
||||||
count: metrics.length,
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
addSpanEvent(span, 'metrics_recorded', { count: metrics.length });
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
metrics_collected: metrics.length,
|
|
||||||
metrics: metrics.map(m => ({ name: m.metric_name, value: m.metric_value })),
|
|
||||||
}),
|
|
||||||
{ headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
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({
|
||||||
|
name: 'collect-metrics',
|
||||||
|
requireAuth: false,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||||
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { edgeLogger } from '../_shared/logger.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
|
|
||||||
interface MetricData {
|
interface MetricData {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -288,13 +289,8 @@ class AnomalyDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createEdgeFunction(
|
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
|
||||||
{
|
addSpanEvent(span, 'starting_anomaly_detection', { requestId });
|
||||||
name: 'detect-anomalies',
|
|
||||||
requireAuth: false,
|
|
||||||
},
|
|
||||||
async (req, context, supabase) => {
|
|
||||||
edgeLogger.info('Starting anomaly detection run', { requestId: context.requestId });
|
|
||||||
|
|
||||||
// Get all enabled anomaly detection configurations
|
// Get all enabled anomaly detection configurations
|
||||||
const { data: configs, error: configError } = await supabase
|
const { data: configs, error: configError } = await supabase
|
||||||
@@ -303,14 +299,11 @@ export default createEdgeFunction(
|
|||||||
.eq('enabled', true);
|
.eq('enabled', true);
|
||||||
|
|
||||||
if (configError) {
|
if (configError) {
|
||||||
console.error('Error fetching configs:', configError);
|
addSpanEvent(span, 'error_fetching_configs', { error: configError.message });
|
||||||
throw configError;
|
throw configError;
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Processing metric configurations', {
|
addSpanEvent(span, 'processing_metric_configs', { count: configs?.length || 0 });
|
||||||
count: configs?.length || 0,
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
const anomaliesDetected: any[] = [];
|
const anomaliesDetected: any[] = [];
|
||||||
|
|
||||||
@@ -327,17 +320,19 @@ export default createEdgeFunction(
|
|||||||
.order('timestamp', { ascending: true });
|
.order('timestamp', { ascending: true });
|
||||||
|
|
||||||
if (metricError) {
|
if (metricError) {
|
||||||
console.error(`Error fetching metric data for ${config.metric_name}:`, metricError);
|
addSpanEvent(span, 'error_fetching_metric_data', {
|
||||||
|
metric: config.metric_name,
|
||||||
|
error: metricError.message
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = metricData as MetricData[];
|
const data = metricData as MetricData[];
|
||||||
|
|
||||||
if (!data || data.length < config.min_data_points) {
|
if (!data || data.length < config.min_data_points) {
|
||||||
edgeLogger.info('Insufficient data for metric', {
|
addSpanEvent(span, 'insufficient_data', {
|
||||||
metric: config.metric_name,
|
metric: config.metric_name,
|
||||||
points: data?.length || 0,
|
points: data?.length || 0
|
||||||
requestId: context.requestId
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -421,7 +416,10 @@ export default createEdgeFunction(
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (anomalyError) {
|
if (anomalyError) {
|
||||||
console.error(`Error inserting anomaly for ${config.metric_name}:`, anomalyError);
|
addSpanEvent(span, 'error_inserting_anomaly', {
|
||||||
|
metric: config.metric_name,
|
||||||
|
error: anomalyError.message
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,21 +451,28 @@ export default createEdgeFunction(
|
|||||||
.update({ alert_created: true, alert_id: alert.id })
|
.update({ alert_created: true, alert_id: alert.id })
|
||||||
.eq('id', anomaly.id);
|
.eq('id', anomaly.id);
|
||||||
|
|
||||||
console.log(`Created alert for anomaly in ${config.metric_name}`);
|
addSpanEvent(span, 'alert_created', {
|
||||||
|
metric: config.metric_name,
|
||||||
|
alertId: alert.id
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Anomaly detected: ${config.metric_name} - ${bestResult.anomalyType} (${bestResult.deviationScore.toFixed(2)}σ)`);
|
addSpanEvent(span, 'anomaly_detected', {
|
||||||
|
metric: config.metric_name,
|
||||||
|
type: bestResult.anomalyType,
|
||||||
|
deviation: bestResult.deviationScore.toFixed(2)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing metric ${config.metric_name}:`, error);
|
addSpanEvent(span, 'error_processing_metric', {
|
||||||
|
metric: config.metric_name,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Anomaly detection complete', {
|
addSpanEvent(span, 'anomaly_detection_complete', { detected: anomaliesDetected.length });
|
||||||
detected: anomaliesDetected.length,
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -475,7 +480,16 @@ export default createEdgeFunction(
|
|||||||
anomalies_detected: anomaliesDetected.length,
|
anomalies_detected: anomaliesDetected.length,
|
||||||
anomalies: anomaliesDetected,
|
anomalies: anomaliesDetected,
|
||||||
}),
|
}),
|
||||||
{ headers: { 'Content-Type': 'application/json' } }
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'detect-anomalies',
|
||||||
|
requireAuth: false,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
|
||||||
interface IPLocationResponse {
|
interface IPLocationResponse {
|
||||||
country: string;
|
country: string;
|
||||||
@@ -11,86 +11,47 @@ interface IPLocationResponse {
|
|||||||
|
|
||||||
// Simple in-memory rate limiter
|
// Simple in-memory rate limiter
|
||||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60000; // 1 minute in milliseconds
|
const RATE_LIMIT_WINDOW = 60000; // 1 minute
|
||||||
const MAX_REQUESTS = 10; // 10 requests per minute per IP
|
const MAX_REQUESTS = 10; // 10 requests per minute per IP
|
||||||
const MAX_MAP_SIZE = 10000; // Maximum number of IPs to track
|
const MAX_MAP_SIZE = 10000;
|
||||||
|
|
||||||
// Cleanup failure tracking to prevent silent failures
|
|
||||||
let cleanupFailureCount = 0;
|
let cleanupFailureCount = 0;
|
||||||
const MAX_CLEANUP_FAILURES = 5; // Threshold before forcing drastic cleanup
|
const MAX_CLEANUP_FAILURES = 5;
|
||||||
const CLEANUP_FAILURE_RESET_INTERVAL = 300000; // Reset failure count every 5 minutes
|
const CLEANUP_FAILURE_RESET_INTERVAL = 300000; // 5 minutes
|
||||||
|
|
||||||
function cleanupExpiredEntries() {
|
function cleanupExpiredEntries() {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let deletedCount = 0;
|
|
||||||
const mapSizeBefore = rateLimitMap.size;
|
|
||||||
|
|
||||||
for (const [ip, data] of rateLimitMap.entries()) {
|
for (const [ip, data] of rateLimitMap.entries()) {
|
||||||
if (now > data.resetAt) {
|
if (now > data.resetAt) {
|
||||||
rateLimitMap.delete(ip);
|
rateLimitMap.delete(ip);
|
||||||
deletedCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup runs silently unless there are issues
|
|
||||||
if (cleanupFailureCount > 0) {
|
if (cleanupFailureCount > 0) {
|
||||||
cleanupFailureCount = 0;
|
cleanupFailureCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// CRITICAL: Increment failure counter and log detailed error information
|
|
||||||
cleanupFailureCount++;
|
cleanupFailureCount++;
|
||||||
|
|
||||||
const errorMessage = formatEdgeError(error);
|
|
||||||
|
|
||||||
edgeLogger.error('Cleanup error', {
|
|
||||||
attempt: cleanupFailureCount,
|
|
||||||
maxAttempts: MAX_CLEANUP_FAILURES,
|
|
||||||
error: errorMessage,
|
|
||||||
mapSize: rateLimitMap.size
|
|
||||||
});
|
|
||||||
|
|
||||||
// FALLBACK MECHANISM: If cleanup fails repeatedly, force clear to prevent memory leak
|
|
||||||
if (cleanupFailureCount >= MAX_CLEANUP_FAILURES) {
|
if (cleanupFailureCount >= MAX_CLEANUP_FAILURES) {
|
||||||
edgeLogger.error('Cleanup critical - forcing emergency cleanup', {
|
|
||||||
consecutiveFailures: cleanupFailureCount,
|
|
||||||
mapSize: rateLimitMap.size
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Emergency: Clear oldest 50% of entries to prevent unbounded growth
|
|
||||||
const entriesToClear = Math.floor(rateLimitMap.size * 0.5);
|
const entriesToClear = Math.floor(rateLimitMap.size * 0.5);
|
||||||
const sortedEntries = Array.from(rateLimitMap.entries())
|
const sortedEntries = Array.from(rateLimitMap.entries())
|
||||||
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||||
|
|
||||||
let clearedCount = 0;
|
|
||||||
for (let i = 0; i < entriesToClear && i < sortedEntries.length; i++) {
|
for (let i = 0; i < entriesToClear && i < sortedEntries.length; i++) {
|
||||||
rateLimitMap.delete(sortedEntries[i][0]);
|
rateLimitMap.delete(sortedEntries[i][0]);
|
||||||
clearedCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.warn('Emergency cleanup completed', { clearedCount, newMapSize: rateLimitMap.size });
|
|
||||||
|
|
||||||
// Reset failure count after emergency cleanup
|
|
||||||
cleanupFailureCount = 0;
|
cleanupFailureCount = 0;
|
||||||
|
} catch {
|
||||||
} catch (emergencyError) {
|
|
||||||
// Last resort: If even emergency cleanup fails, clear everything
|
|
||||||
const originalSize = rateLimitMap.size;
|
|
||||||
rateLimitMap.clear();
|
rateLimitMap.clear();
|
||||||
|
|
||||||
edgeLogger.error('Emergency cleanup failed - cleared entire map', {
|
|
||||||
originalSize,
|
|
||||||
error: emergencyError instanceof Error ? emergencyError.message : String(emergencyError)
|
|
||||||
});
|
|
||||||
cleanupFailureCount = 0;
|
cleanupFailureCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset cleanup failure count periodically to avoid permanent emergency state
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (cleanupFailureCount > 0) {
|
if (cleanupFailureCount > 0) {
|
||||||
cleanupFailureCount = 0;
|
cleanupFailureCount = 0;
|
||||||
@@ -101,7 +62,6 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = rateLimitMap.get(ip);
|
const existing = rateLimitMap.get(ip);
|
||||||
|
|
||||||
// Handle existing entries (most common case - early return for performance)
|
|
||||||
if (existing && now <= existing.resetAt) {
|
if (existing && now <= existing.resetAt) {
|
||||||
if (existing.count >= MAX_REQUESTS) {
|
if (existing.count >= MAX_REQUESTS) {
|
||||||
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
||||||
@@ -111,208 +71,108 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
|
|||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to add new entry or reset expired one
|
|
||||||
// Only perform cleanup if we're at capacity AND adding a new IP
|
|
||||||
if (!existing && rateLimitMap.size >= MAX_MAP_SIZE) {
|
if (!existing && rateLimitMap.size >= MAX_MAP_SIZE) {
|
||||||
// First try cleaning expired entries
|
|
||||||
cleanupExpiredEntries();
|
cleanupExpiredEntries();
|
||||||
|
|
||||||
// If still at capacity after cleanup, remove oldest entries (LRU eviction)
|
|
||||||
if (rateLimitMap.size >= MAX_MAP_SIZE) {
|
if (rateLimitMap.size >= MAX_MAP_SIZE) {
|
||||||
try {
|
try {
|
||||||
const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); // Remove 30% of entries
|
const toDelete = Math.floor(MAX_MAP_SIZE * 0.3);
|
||||||
const sortedEntries = Array.from(rateLimitMap.entries())
|
const sortedEntries = Array.from(rateLimitMap.entries())
|
||||||
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||||
|
|
||||||
let deletedCount = 0;
|
|
||||||
for (let i = 0; i < toDelete && i < sortedEntries.length; i++) {
|
for (let i = 0; i < toDelete && i < sortedEntries.length; i++) {
|
||||||
rateLimitMap.delete(sortedEntries[i][0]);
|
rateLimitMap.delete(sortedEntries[i][0]);
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.warn('Rate limit map at capacity - evicted entries', {
|
|
||||||
maxSize: MAX_MAP_SIZE,
|
|
||||||
deletedCount,
|
|
||||||
newSize: rateLimitMap.size
|
|
||||||
});
|
|
||||||
} catch (evictionError) {
|
|
||||||
// CRITICAL: LRU eviction failed - log error and attempt emergency clear
|
|
||||||
edgeLogger.error('LRU eviction failed', {
|
|
||||||
error: evictionError instanceof Error ? evictionError.message : String(evictionError),
|
|
||||||
mapSize: rateLimitMap.size
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Emergency: Clear first 30% of entries without sorting
|
|
||||||
const targetSize = Math.floor(MAX_MAP_SIZE * 0.7);
|
|
||||||
const keysToDelete: string[] = [];
|
|
||||||
|
|
||||||
for (const [key] of rateLimitMap.entries()) {
|
|
||||||
if (rateLimitMap.size <= targetSize) break;
|
|
||||||
keysToDelete.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
keysToDelete.forEach(key => rateLimitMap.delete(key));
|
|
||||||
|
|
||||||
edgeLogger.warn('Emergency eviction completed', {
|
|
||||||
clearedCount: keysToDelete.length,
|
|
||||||
newSize: rateLimitMap.size
|
|
||||||
});
|
|
||||||
} catch (emergencyError) {
|
|
||||||
edgeLogger.error('Emergency eviction failed - clearing entire map', {
|
|
||||||
error: emergencyError instanceof Error ? emergencyError.message : String(emergencyError)
|
|
||||||
});
|
|
||||||
rateLimitMap.clear();
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
rateLimitMap.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new entry or reset expired entry
|
|
||||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up old entries periodically to prevent memory leak
|
setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000));
|
||||||
// Run cleanup more frequently to catch expired entries sooner
|
|
||||||
setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000)); // Every 30 seconds or half the window
|
|
||||||
|
|
||||||
serve(async (req) => {
|
|
||||||
// Handle CORS preflight requests
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracking = startRequest('detect-location');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the client's IP address
|
|
||||||
const forwarded = req.headers.get('x-forwarded-for');
|
|
||||||
const realIP = req.headers.get('x-real-ip');
|
|
||||||
const clientIP = forwarded?.split(',')[0] || realIP || '8.8.8.8'; // fallback to Google DNS for testing
|
|
||||||
|
|
||||||
// Check rate limit
|
|
||||||
const rateLimit = checkRateLimit(clientIP);
|
|
||||||
if (!rateLimit.allowed) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Rate limit exceeded',
|
|
||||||
message: 'Too many requests. Please try again later.',
|
|
||||||
retryAfter: rateLimit.retryAfter
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Retry-After': String(rateLimit.retryAfter || 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PII Note: Do not log full IP addresses in production
|
|
||||||
edgeLogger.info('Detecting location for request', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
// Use configurable geolocation service with proper error handling
|
|
||||||
// Defaults to ip-api.com if not configured
|
|
||||||
const geoApiUrl = Deno.env.get('GEOLOCATION_API_URL') || 'http://ip-api.com/json';
|
|
||||||
const geoApiFields = Deno.env.get('GEOLOCATION_API_FIELDS') || 'status,country,countryCode';
|
|
||||||
|
|
||||||
let geoResponse;
|
|
||||||
try {
|
|
||||||
geoResponse = await fetch(`${geoApiUrl}/${clientIP}?fields=${geoApiFields}`);
|
|
||||||
} catch (fetchError) {
|
|
||||||
edgeLogger.error('Network error fetching location data', {
|
|
||||||
error: fetchError instanceof Error ? fetchError.message : String(fetchError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
throw new Error('Network error: Unable to reach geolocation service');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!geoResponse.ok) {
|
|
||||||
throw new Error(`Geolocation service returned ${geoResponse.status}: ${geoResponse.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let geoData;
|
|
||||||
try {
|
|
||||||
geoData = await geoResponse.json();
|
|
||||||
} catch (parseError) {
|
|
||||||
edgeLogger.error('Failed to parse geolocation response', {
|
|
||||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
throw new Error('Invalid response format from geolocation service');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geoData.status !== 'success') {
|
|
||||||
throw new Error(`Geolocation failed: ${geoData.message || 'Invalid location data'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Countries that primarily use imperial system
|
|
||||||
const imperialCountries = ['US', 'LR', 'MM']; // USA, Liberia, Myanmar
|
|
||||||
const measurementSystem = imperialCountries.includes(geoData.countryCode) ? 'imperial' : 'metric';
|
|
||||||
|
|
||||||
const result: IPLocationResponse = {
|
|
||||||
country: geoData.country,
|
|
||||||
countryCode: geoData.countryCode,
|
|
||||||
measurementSystem
|
|
||||||
};
|
|
||||||
|
|
||||||
edgeLogger.info('Location detected', {
|
|
||||||
country: result.country,
|
|
||||||
countryCode: result.countryCode,
|
|
||||||
measurementSystem: result.measurementSystem,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
endRequest(tracking);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ ...result, requestId: tracking.requestId }),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Enhanced error logging for better visibility and debugging
|
|
||||||
const errorMessage = formatEdgeError(error);
|
|
||||||
|
|
||||||
edgeLogger.error('Location detection error', {
|
|
||||||
error: errorMessage,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
endRequest(tracking);
|
|
||||||
|
|
||||||
// Return default (metric) with 500 status to indicate error occurred
|
|
||||||
// This allows proper error monitoring while still providing fallback data
|
|
||||||
const defaultResult: IPLocationResponse = {
|
|
||||||
country: 'Unknown',
|
|
||||||
countryCode: 'XX',
|
|
||||||
measurementSystem: 'metric'
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const handler = async (req: Request, { span, requestId }: EdgeFunctionContext) => {
|
||||||
|
// Get client IP
|
||||||
|
const forwarded = req.headers.get('x-forwarded-for');
|
||||||
|
const realIP = req.headers.get('x-real-ip');
|
||||||
|
const clientIP = forwarded?.split(',')[0] || realIP || '8.8.8.8';
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const rateLimit = checkRateLimit(clientIP);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
addSpanEvent(span, 'rate_limit_exceeded', { clientIP: clientIP.substring(0, 8) + '...' });
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...defaultResult,
|
error: 'Rate limit exceeded',
|
||||||
error: errorMessage,
|
message: 'Too many requests. Please try again later.',
|
||||||
fallback: true,
|
retryAfter: rateLimit.retryAfter
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
status: 429,
|
||||||
...corsHeaders,
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Retry-After': String(rateLimit.retryAfter || 60)
|
||||||
'X-Request-ID': tracking.requestId
|
}
|
||||||
},
|
|
||||||
status: 500
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
addSpanEvent(span, 'detecting_location', { requestId });
|
||||||
|
|
||||||
|
// Use configurable geolocation service
|
||||||
|
const geoApiUrl = Deno.env.get('GEOLOCATION_API_URL') || 'http://ip-api.com/json';
|
||||||
|
const geoApiFields = Deno.env.get('GEOLOCATION_API_FIELDS') || 'status,country,countryCode';
|
||||||
|
|
||||||
|
let geoResponse;
|
||||||
|
try {
|
||||||
|
geoResponse = await fetch(`${geoApiUrl}/${clientIP}?fields=${geoApiFields}`);
|
||||||
|
} catch (fetchError) {
|
||||||
|
addSpanEvent(span, 'network_error', { error: fetchError instanceof Error ? fetchError.message : String(fetchError) });
|
||||||
|
throw new Error('Network error: Unable to reach geolocation service');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geoResponse.ok) {
|
||||||
|
throw new Error(`Geolocation service returned ${geoResponse.status}: ${geoResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let geoData;
|
||||||
|
try {
|
||||||
|
geoData = await geoResponse.json();
|
||||||
|
} catch (parseError) {
|
||||||
|
addSpanEvent(span, 'parse_error', { error: parseError instanceof Error ? parseError.message : String(parseError) });
|
||||||
|
throw new Error('Invalid response format from geolocation service');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geoData.status !== 'success') {
|
||||||
|
throw new Error(`Geolocation failed: ${geoData.message || 'Invalid location data'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Countries that primarily use imperial system
|
||||||
|
const imperialCountries = ['US', 'LR', 'MM'];
|
||||||
|
const measurementSystem = imperialCountries.includes(geoData.countryCode) ? 'imperial' : 'metric';
|
||||||
|
|
||||||
|
const result: IPLocationResponse = {
|
||||||
|
country: geoData.country,
|
||||||
|
countryCode: geoData.countryCode,
|
||||||
|
measurementSystem
|
||||||
|
};
|
||||||
|
|
||||||
|
addSpanEvent(span, 'location_detected', {
|
||||||
|
country: result.country,
|
||||||
|
countryCode: result.countryCode,
|
||||||
|
measurementSystem: result.measurementSystem
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'detect-location',
|
||||||
|
requireAuth: false,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
logRequests: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
import { sanitizeError } from '../_shared/errorSanitizer.ts';
|
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
|
||||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
|
||||||
|
|
||||||
interface ExportOptions {
|
interface ExportOptions {
|
||||||
include_reviews: boolean;
|
include_reviews: boolean;
|
||||||
@@ -14,357 +11,249 @@ interface ExportOptions {
|
|||||||
format: 'json';
|
format: 'json';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply strict rate limiting (5 req/min) for expensive data export operations
|
const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => {
|
||||||
// This prevents abuse and manages server load from large data exports
|
addSpanEvent(span, 'processing_export_request', { userId: user.id });
|
||||||
serve(withRateLimit(async (req) => {
|
|
||||||
const tracking = startRequest();
|
// Additional rate limiting - max 1 export per hour
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
// Handle CORS preflight requests
|
const { data: recentExports, error: rateLimitError } = await supabase
|
||||||
if (req.method === 'OPTIONS') {
|
.from('profile_audit_log')
|
||||||
return new Response(null, {
|
.select('created_at')
|
||||||
headers: {
|
.eq('user_id', user.id)
|
||||||
...corsHeaders,
|
.eq('action', 'data_exported')
|
||||||
'X-Request-ID': tracking.requestId
|
.gte('created_at', oneHourAgo)
|
||||||
}
|
.limit(1);
|
||||||
});
|
|
||||||
|
if (rateLimitError) {
|
||||||
|
addSpanEvent(span, 'rate_limit_check_failed', { error: rateLimitError.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (recentExports && recentExports.length > 0) {
|
||||||
const supabaseClient = createClient(
|
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
|
||||||
Deno.env.get('SUPABASE_URL') ?? '',
|
addSpanEvent(span, 'rate_limit_exceeded', { nextAvailableAt });
|
||||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
|
||||||
{
|
return new Response(
|
||||||
global: {
|
JSON.stringify({
|
||||||
headers: { Authorization: req.headers.get('Authorization')! },
|
success: false,
|
||||||
},
|
error: 'Rate limited. You can export your data once per hour.',
|
||||||
}
|
rate_limited: true,
|
||||||
|
next_available_at: nextAvailableAt,
|
||||||
|
}),
|
||||||
|
{ status: 429 }
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get authenticated user
|
// Parse export options
|
||||||
const {
|
const body = await req.json();
|
||||||
data: { user },
|
const options: ExportOptions = {
|
||||||
error: authError,
|
include_reviews: body.include_reviews ?? true,
|
||||||
} = await supabaseClient.auth.getUser();
|
include_lists: body.include_lists ?? true,
|
||||||
|
include_activity_log: body.include_activity_log ?? true,
|
||||||
|
include_preferences: body.include_preferences ?? true,
|
||||||
|
format: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
if (authError || !user) {
|
addSpanEvent(span, 'export_options_parsed', { options });
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Authentication failed', {
|
|
||||||
action: 'export_auth',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Unauthorized',
|
|
||||||
success: false,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Processing export request', {
|
// Fetch profile data
|
||||||
action: 'export_start',
|
const { data: profile, error: profileError } = await supabase
|
||||||
requestId: tracking.requestId,
|
.from('profiles')
|
||||||
userId: user.id
|
.select('username, display_name, bio, preferred_pronouns, personal_location, timezone, preferred_language, theme_preference, privacy_level, ride_count, coaster_count, park_count, review_count, reputation_score, created_at, updated_at')
|
||||||
});
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
// Check rate limiting - max 1 export per hour
|
if (profileError) {
|
||||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
addSpanEvent(span, 'profile_fetch_failed', { error: profileError.message });
|
||||||
const { data: recentExports, error: rateLimitError } = await supabaseClient
|
throw new Error('Failed to fetch profile data');
|
||||||
.from('profile_audit_log')
|
}
|
||||||
.select('created_at')
|
|
||||||
|
// Fetch statistics
|
||||||
|
const { count: photoCount } = await supabase
|
||||||
|
.from('photos')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('submitted_by', user.id);
|
||||||
|
|
||||||
|
const { count: listCount } = await supabase
|
||||||
|
.from('user_lists')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
|
const { count: submissionCount } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
|
const statistics = {
|
||||||
|
ride_count: profile.ride_count || 0,
|
||||||
|
coaster_count: profile.coaster_count || 0,
|
||||||
|
park_count: profile.park_count || 0,
|
||||||
|
review_count: profile.review_count || 0,
|
||||||
|
reputation_score: profile.reputation_score || 0,
|
||||||
|
photo_count: photoCount || 0,
|
||||||
|
list_count: listCount || 0,
|
||||||
|
submission_count: submissionCount || 0,
|
||||||
|
account_created: profile.created_at,
|
||||||
|
last_updated: profile.updated_at
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch reviews if requested
|
||||||
|
let reviews = [];
|
||||||
|
if (options.include_reviews) {
|
||||||
|
const { data: reviewsData, error: reviewsError } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
rating,
|
||||||
|
review_text,
|
||||||
|
created_at,
|
||||||
|
rides(name),
|
||||||
|
parks(name)
|
||||||
|
`)
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.eq('action', 'data_exported')
|
.order('created_at', { ascending: false });
|
||||||
.gte('created_at', oneHourAgo)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (rateLimitError) {
|
if (!reviewsError && reviewsData) {
|
||||||
edgeLogger.error('Rate limit check failed', { action: 'export_rate_limit', requestId: tracking.requestId, error: rateLimitError });
|
reviews = reviewsData.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
rating: r.rating,
|
||||||
|
review_text: r.review_text,
|
||||||
|
ride_name: r.rides?.name,
|
||||||
|
park_name: r.parks?.name,
|
||||||
|
created_at: r.created_at
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (recentExports && recentExports.length > 0) {
|
// Fetch lists if requested
|
||||||
const duration = endRequest(tracking);
|
let lists = [];
|
||||||
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
|
if (options.include_lists) {
|
||||||
edgeLogger.warn('Rate limit exceeded for export', {
|
const { data: listsData, error: listsError } = await supabase
|
||||||
action: 'export_rate_limit',
|
.from('user_lists')
|
||||||
requestId: tracking.requestId,
|
.select('id, name, description, is_public, created_at')
|
||||||
userId: user.id,
|
.eq('user_id', user.id)
|
||||||
duration,
|
.order('created_at', { ascending: false });
|
||||||
nextAvailableAt
|
|
||||||
});
|
if (!listsError && listsData) {
|
||||||
return new Response(
|
lists = await Promise.all(
|
||||||
JSON.stringify({
|
listsData.map(async (list) => {
|
||||||
success: false,
|
const { count } = await supabase
|
||||||
error: 'Rate limited. You can export your data once per hour.',
|
.from('user_list_items')
|
||||||
rate_limited: true,
|
.select('*', { count: 'exact', head: true })
|
||||||
next_available_at: nextAvailableAt,
|
.eq('list_id', list.id);
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
return {
|
||||||
{
|
id: list.id,
|
||||||
status: 429,
|
name: list.name,
|
||||||
headers: {
|
description: list.description,
|
||||||
...corsHeaders,
|
is_public: list.is_public,
|
||||||
'Content-Type': 'application/json',
|
item_count: count || 0,
|
||||||
'X-Request-ID': tracking.requestId
|
created_at: list.created_at
|
||||||
}
|
};
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse export options
|
// Fetch activity log if requested
|
||||||
const body = await req.json();
|
let activity_log = [];
|
||||||
const options: ExportOptions = {
|
if (options.include_activity_log) {
|
||||||
include_reviews: body.include_reviews ?? true,
|
const { data: activityData, error: activityError } = await supabase
|
||||||
include_lists: body.include_lists ?? true,
|
.from('profile_audit_log')
|
||||||
include_activity_log: body.include_activity_log ?? true,
|
.select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent')
|
||||||
include_preferences: body.include_preferences ?? true,
|
.eq('user_id', user.id)
|
||||||
format: 'json'
|
.order('created_at', { ascending: false })
|
||||||
};
|
.limit(100);
|
||||||
|
|
||||||
edgeLogger.info('Export options', {
|
if (!activityError && activityData) {
|
||||||
action: 'export_options',
|
activity_log = activityData;
|
||||||
requestId: tracking.requestId,
|
}
|
||||||
userId: user.id
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch profile data
|
// Fetch preferences if requested
|
||||||
const { data: profile, error: profileError } = await supabaseClient
|
let preferences = {
|
||||||
.from('profiles')
|
unit_preferences: null,
|
||||||
.select('username, display_name, bio, preferred_pronouns, personal_location, timezone, preferred_language, theme_preference, privacy_level, ride_count, coaster_count, park_count, review_count, reputation_score, created_at, updated_at')
|
accessibility_options: null,
|
||||||
|
notification_preferences: null,
|
||||||
|
privacy_settings: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.include_preferences) {
|
||||||
|
const { data: prefsData } = await supabase
|
||||||
|
.from('user_preferences')
|
||||||
|
.select('unit_preferences, accessibility_options, notification_preferences, privacy_settings')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (profileError) {
|
if (prefsData) {
|
||||||
edgeLogger.error('Profile fetch failed', {
|
preferences = prefsData;
|
||||||
action: 'export_profile',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
throw new Error('Failed to fetch profile data');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch statistics
|
|
||||||
const { count: photoCount } = await supabaseClient
|
|
||||||
.from('photos')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('submitted_by', user.id);
|
|
||||||
|
|
||||||
const { count: listCount } = await supabaseClient
|
|
||||||
.from('user_lists')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
const { count: submissionCount } = await supabaseClient
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
const statistics = {
|
|
||||||
ride_count: profile.ride_count || 0,
|
|
||||||
coaster_count: profile.coaster_count || 0,
|
|
||||||
park_count: profile.park_count || 0,
|
|
||||||
review_count: profile.review_count || 0,
|
|
||||||
reputation_score: profile.reputation_score || 0,
|
|
||||||
photo_count: photoCount || 0,
|
|
||||||
list_count: listCount || 0,
|
|
||||||
submission_count: submissionCount || 0,
|
|
||||||
account_created: profile.created_at,
|
|
||||||
last_updated: profile.updated_at
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch reviews if requested
|
|
||||||
let reviews = [];
|
|
||||||
if (options.include_reviews) {
|
|
||||||
const { data: reviewsData, error: reviewsError } = await supabaseClient
|
|
||||||
.from('reviews')
|
|
||||||
.select(`
|
|
||||||
id,
|
|
||||||
rating,
|
|
||||||
review_text,
|
|
||||||
created_at,
|
|
||||||
rides(name),
|
|
||||||
parks(name)
|
|
||||||
`)
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (!reviewsError && reviewsData) {
|
|
||||||
reviews = reviewsData.map(r => ({
|
|
||||||
id: r.id,
|
|
||||||
rating: r.rating,
|
|
||||||
review_text: r.review_text,
|
|
||||||
ride_name: r.rides?.name,
|
|
||||||
park_name: r.parks?.name,
|
|
||||||
created_at: r.created_at
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch lists if requested
|
|
||||||
let lists = [];
|
|
||||||
if (options.include_lists) {
|
|
||||||
const { data: listsData, error: listsError } = await supabaseClient
|
|
||||||
.from('user_lists')
|
|
||||||
.select('id, name, description, is_public, created_at')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (!listsError && listsData) {
|
|
||||||
lists = await Promise.all(
|
|
||||||
listsData.map(async (list) => {
|
|
||||||
const { count } = await supabaseClient
|
|
||||||
.from('user_list_items')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('list_id', list.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: list.id,
|
|
||||||
name: list.name,
|
|
||||||
description: list.description,
|
|
||||||
is_public: list.is_public,
|
|
||||||
item_count: count || 0,
|
|
||||||
created_at: list.created_at
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch activity log if requested
|
|
||||||
let activity_log = [];
|
|
||||||
if (options.include_activity_log) {
|
|
||||||
const { data: activityData, error: activityError } = await supabaseClient
|
|
||||||
.from('profile_audit_log')
|
|
||||||
.select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(100);
|
|
||||||
|
|
||||||
if (!activityError && activityData) {
|
|
||||||
activity_log = activityData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch preferences if requested
|
|
||||||
let preferences = {
|
|
||||||
unit_preferences: null,
|
|
||||||
accessibility_options: null,
|
|
||||||
notification_preferences: null,
|
|
||||||
privacy_settings: null
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.include_preferences) {
|
|
||||||
const { data: prefsData } = await supabaseClient
|
|
||||||
.from('user_preferences')
|
|
||||||
.select('unit_preferences, accessibility_options, notification_preferences, privacy_settings')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (prefsData) {
|
|
||||||
preferences = prefsData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build export data structure
|
|
||||||
const exportData = {
|
|
||||||
export_date: new Date().toISOString(),
|
|
||||||
user_id: user.id,
|
|
||||||
profile: {
|
|
||||||
username: profile.username,
|
|
||||||
display_name: profile.display_name,
|
|
||||||
bio: profile.bio,
|
|
||||||
preferred_pronouns: profile.preferred_pronouns,
|
|
||||||
personal_location: profile.personal_location,
|
|
||||||
timezone: profile.timezone,
|
|
||||||
preferred_language: profile.preferred_language,
|
|
||||||
theme_preference: profile.theme_preference,
|
|
||||||
privacy_level: profile.privacy_level,
|
|
||||||
created_at: profile.created_at,
|
|
||||||
updated_at: profile.updated_at
|
|
||||||
},
|
|
||||||
statistics,
|
|
||||||
reviews,
|
|
||||||
lists,
|
|
||||||
activity_log,
|
|
||||||
preferences,
|
|
||||||
metadata: {
|
|
||||||
export_version: '1.0.0',
|
|
||||||
data_retention_info: 'Your data is retained according to our privacy policy. You can request deletion at any time from your account settings.',
|
|
||||||
instructions: 'This file contains all your personal data stored in ThrillWiki. You can use this for backup purposes or to migrate to another service. For questions, contact support@thrillwiki.com'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log the export action
|
|
||||||
await supabaseClient.from('profile_audit_log').insert([{
|
|
||||||
user_id: user.id,
|
|
||||||
changed_by: user.id,
|
|
||||||
action: 'data_exported',
|
|
||||||
changes: {
|
|
||||||
export_options: options,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
data_size: JSON.stringify(exportData).length,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}
|
|
||||||
}]);
|
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Export completed successfully', {
|
|
||||||
action: 'export_complete',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
traceId: tracking.traceId,
|
|
||||||
userId: user.id,
|
|
||||||
duration,
|
|
||||||
dataSize: JSON.stringify(exportData).length
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
data: exportData,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`,
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Export error', {
|
|
||||||
action: 'export_error',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: formatEdgeError(error)
|
|
||||||
});
|
|
||||||
const sanitized = sanitizeError(error, 'export-user-data');
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
...sanitized,
|
|
||||||
success: false,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, rateLimiters.strict, corsHeaders));
|
|
||||||
|
// Build export data structure
|
||||||
|
const exportData = {
|
||||||
|
export_date: new Date().toISOString(),
|
||||||
|
user_id: user.id,
|
||||||
|
profile: {
|
||||||
|
username: profile.username,
|
||||||
|
display_name: profile.display_name,
|
||||||
|
bio: profile.bio,
|
||||||
|
preferred_pronouns: profile.preferred_pronouns,
|
||||||
|
personal_location: profile.personal_location,
|
||||||
|
timezone: profile.timezone,
|
||||||
|
preferred_language: profile.preferred_language,
|
||||||
|
theme_preference: profile.theme_preference,
|
||||||
|
privacy_level: profile.privacy_level,
|
||||||
|
created_at: profile.created_at,
|
||||||
|
updated_at: profile.updated_at
|
||||||
|
},
|
||||||
|
statistics,
|
||||||
|
reviews,
|
||||||
|
lists,
|
||||||
|
activity_log,
|
||||||
|
preferences,
|
||||||
|
metadata: {
|
||||||
|
export_version: '1.0.0',
|
||||||
|
data_retention_info: 'Your data is retained according to our privacy policy. You can request deletion at any time from your account settings.',
|
||||||
|
instructions: 'This file contains all your personal data stored in ThrillWiki. You can use this for backup purposes or to migrate to another service. For questions, contact support@thrillwiki.com'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log the export action
|
||||||
|
await supabase.from('profile_audit_log').insert([{
|
||||||
|
user_id: user.id,
|
||||||
|
changed_by: user.id,
|
||||||
|
action: 'data_exported',
|
||||||
|
changes: {
|
||||||
|
export_options: options,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data_size: JSON.stringify(exportData).length,
|
||||||
|
requestId
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
|
||||||
|
addSpanEvent(span, 'export_completed', {
|
||||||
|
dataSize: JSON.stringify(exportData).length
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: exportData,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'export-user-data',
|
||||||
|
requireAuth: true,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
logRequests: true,
|
||||||
|
logResponses: true,
|
||||||
|
rateLimitTier: 'strict', // 5 requests per minute
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
import { createErrorResponse, sanitizeError } from '../_shared/errorSanitizer.ts';
|
|
||||||
|
|
||||||
interface MergeTicketsRequest {
|
interface MergeTicketsRequest {
|
||||||
primaryTicketId: string;
|
primaryTicketId: string;
|
||||||
@@ -18,268 +17,189 @@ interface MergeTicketsResponse {
|
|||||||
deletedTickets: string[];
|
deletedTickets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
serve(async (req) => {
|
const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => {
|
||||||
const tracking = startRequest();
|
// Parse request body
|
||||||
|
const { primaryTicketId, mergeTicketIds, mergeReason }: MergeTicketsRequest = await req.json();
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
// Validation
|
||||||
return new Response(null, { headers: corsHeaders });
|
if (!primaryTicketId || !mergeTicketIds || mergeTicketIds.length === 0) {
|
||||||
|
throw new Error('Invalid request: primaryTicketId and mergeTicketIds required');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (mergeTicketIds.includes(primaryTicketId)) {
|
||||||
const authHeader = req.headers.get('Authorization');
|
throw new Error('Cannot merge a ticket into itself');
|
||||||
if (!authHeader) {
|
|
||||||
throw new Error('Missing authorization header');
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createClient(
|
|
||||||
Deno.env.get('SUPABASE_URL') ?? '',
|
|
||||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
|
||||||
{ global: { headers: { Authorization: authHeader } } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Authenticate user
|
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
||||||
if (authError || !user) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Merge tickets request started', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if user has moderator/admin role
|
|
||||||
const { data: hasRole, error: roleError } = await supabase.rpc('has_role', {
|
|
||||||
_user_id: user.id,
|
|
||||||
_role: 'moderator'
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: isAdmin, error: adminError } = await supabase.rpc('has_role', {
|
|
||||||
_user_id: user.id,
|
|
||||||
_role: 'admin'
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: isSuperuser, error: superuserError } = await supabase.rpc('has_role', {
|
|
||||||
_user_id: user.id,
|
|
||||||
_role: 'superuser'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (roleError || adminError || superuserError || (!hasRole && !isAdmin && !isSuperuser)) {
|
|
||||||
throw new Error('Insufficient permissions. Moderator role required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
const { primaryTicketId, mergeTicketIds, mergeReason }: MergeTicketsRequest = await req.json();
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!primaryTicketId || !mergeTicketIds || mergeTicketIds.length === 0) {
|
|
||||||
throw new Error('Invalid request: primaryTicketId and mergeTicketIds required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mergeTicketIds.includes(primaryTicketId)) {
|
|
||||||
throw new Error('Cannot merge a ticket into itself');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mergeTicketIds.length > 10) {
|
|
||||||
throw new Error('Maximum 10 tickets can be merged at once');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start transaction-like operations
|
|
||||||
const allTicketIds = [primaryTicketId, ...mergeTicketIds];
|
|
||||||
|
|
||||||
// Fetch all tickets
|
|
||||||
const { data: tickets, error: fetchError } = await supabase
|
|
||||||
.from('contact_submissions')
|
|
||||||
.select('id, ticket_number, admin_notes, merged_ticket_numbers')
|
|
||||||
.in('id', allTicketIds);
|
|
||||||
|
|
||||||
if (fetchError) throw fetchError;
|
|
||||||
if (!tickets || tickets.length !== allTicketIds.length) {
|
|
||||||
throw new Error('One or more tickets not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryTicket = tickets.find(t => t.id === primaryTicketId);
|
|
||||||
const mergeTickets = tickets.filter(t => mergeTicketIds.includes(t.id));
|
|
||||||
|
|
||||||
if (!primaryTicket) {
|
|
||||||
throw new Error('Primary ticket not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any ticket already has merged_ticket_numbers (prevent re-merging)
|
|
||||||
const alreadyMerged = tickets.find(t =>
|
|
||||||
t.merged_ticket_numbers && t.merged_ticket_numbers.length > 0
|
|
||||||
);
|
|
||||||
if (alreadyMerged) {
|
|
||||||
throw new Error(`Ticket ${alreadyMerged.ticket_number} has already been used in a merge`);
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Starting merge process', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
primaryTicket: primaryTicket.ticket_number,
|
|
||||||
mergeTicketCount: mergeTickets.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Move all email threads to primary ticket
|
|
||||||
edgeLogger.info('Step 1: Moving email threads', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
fromTickets: mergeTickets.map(t => t.ticket_number)
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: movedThreads, error: moveError } = await supabase
|
|
||||||
.from('contact_email_threads')
|
|
||||||
.update({ submission_id: primaryTicketId })
|
|
||||||
.in('submission_id', mergeTicketIds)
|
|
||||||
.select('id');
|
|
||||||
|
|
||||||
if (moveError) throw moveError;
|
|
||||||
|
|
||||||
const threadsMovedCount = movedThreads?.length || 0;
|
|
||||||
|
|
||||||
edgeLogger.info('Threads moved successfully', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
threadsMovedCount
|
|
||||||
});
|
|
||||||
|
|
||||||
if (threadsMovedCount === 0) {
|
|
||||||
edgeLogger.warn('No email threads found to move', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
mergeTicketIds
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Consolidate admin notes
|
|
||||||
edgeLogger.info('Step 2: Consolidating admin notes', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
let consolidatedNotes = primaryTicket.admin_notes || '';
|
|
||||||
|
|
||||||
for (const ticket of mergeTickets) {
|
|
||||||
if (ticket.admin_notes) {
|
|
||||||
consolidatedNotes = consolidatedNotes.trim()
|
|
||||||
? `${consolidatedNotes}\n\n${ticket.admin_notes}`
|
|
||||||
: ticket.admin_notes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Recalculate metadata from consolidated threads
|
|
||||||
edgeLogger.info('Step 3: Recalculating metadata from threads', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
const { data: threadStats, error: statsError } = await supabase
|
|
||||||
.from('contact_email_threads')
|
|
||||||
.select('direction, created_at')
|
|
||||||
.eq('submission_id', primaryTicketId);
|
|
||||||
|
|
||||||
if (statsError) throw statsError;
|
|
||||||
|
|
||||||
const outboundCount = threadStats?.filter(t => t.direction === 'outbound').length || 0;
|
|
||||||
const lastAdminResponse = threadStats
|
|
||||||
?.filter(t => t.direction === 'outbound')
|
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at;
|
|
||||||
const lastUserResponse = threadStats
|
|
||||||
?.filter(t => t.direction === 'inbound')
|
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at;
|
|
||||||
|
|
||||||
edgeLogger.info('Metadata recalculated', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
outboundCount,
|
|
||||||
lastAdminResponse,
|
|
||||||
lastUserResponse
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get merged ticket numbers
|
|
||||||
const mergedTicketNumbers = mergeTickets.map(t => t.ticket_number);
|
|
||||||
|
|
||||||
// Step 4: Update primary ticket with consolidated data
|
|
||||||
edgeLogger.info('Step 4: Updating primary ticket', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from('contact_submissions')
|
|
||||||
.update({
|
|
||||||
admin_notes: consolidatedNotes,
|
|
||||||
response_count: outboundCount,
|
|
||||||
last_admin_response_at: lastAdminResponse || null,
|
|
||||||
merged_ticket_numbers: [
|
|
||||||
...(primaryTicket.merged_ticket_numbers || []),
|
|
||||||
...mergedTicketNumbers
|
|
||||||
],
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq('id', primaryTicketId);
|
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
|
|
||||||
edgeLogger.info('Primary ticket updated successfully', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
// Step 5: Delete merged tickets
|
|
||||||
edgeLogger.info('Step 5: Deleting merged tickets', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
ticketsToDelete: mergeTicketIds.length
|
|
||||||
});
|
|
||||||
|
|
||||||
const { error: deleteError } = await supabase
|
|
||||||
.from('contact_submissions')
|
|
||||||
.delete()
|
|
||||||
.in('id', mergeTicketIds);
|
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
|
||||||
|
|
||||||
edgeLogger.info('Merged tickets deleted successfully', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
// Step 6: Audit log
|
|
||||||
edgeLogger.info('Step 6: Creating audit log', { requestId: tracking.requestId });
|
|
||||||
|
|
||||||
const { error: auditError } = await supabase.from('admin_audit_log').insert({
|
|
||||||
admin_user_id: user.id,
|
|
||||||
target_user_id: user.id, // No specific target user for this action
|
|
||||||
action: 'merge_contact_tickets',
|
|
||||||
details: {
|
|
||||||
primary_ticket_id: primaryTicketId,
|
|
||||||
primary_ticket_number: primaryTicket.ticket_number,
|
|
||||||
merged_ticket_ids: mergeTicketIds,
|
|
||||||
merged_ticket_numbers: mergedTicketNumbers,
|
|
||||||
merge_reason: mergeReason || null,
|
|
||||||
threads_moved: threadsMovedCount,
|
|
||||||
merged_count: mergeTickets.length,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (auditError) {
|
|
||||||
edgeLogger.warn('Failed to create audit log for merge', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
error: auditError.message,
|
|
||||||
primaryTicket: primaryTicket.ticket_number
|
|
||||||
});
|
|
||||||
// Don't throw - merge already succeeded
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Merge tickets completed successfully', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
primaryTicket: primaryTicket.ticket_number,
|
|
||||||
mergedCount: mergeTickets.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: MergeTicketsResponse = {
|
|
||||||
success: true,
|
|
||||||
primaryTicketNumber: primaryTicket.ticket_number,
|
|
||||||
mergedCount: mergeTickets.length,
|
|
||||||
threadsConsolidated: threadsMovedCount,
|
|
||||||
deletedTickets: mergedTicketNumbers,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(response), {
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Merge tickets failed', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
|
|
||||||
return createErrorResponse(error, 500, corsHeaders, 'merge_contact_tickets');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (mergeTicketIds.length > 10) {
|
||||||
|
throw new Error('Maximum 10 tickets can be merged at once');
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpanEvent(span, 'merge_tickets_started', {
|
||||||
|
primaryTicketId,
|
||||||
|
mergeCount: mergeTicketIds.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start transaction-like operations
|
||||||
|
const allTicketIds = [primaryTicketId, ...mergeTicketIds];
|
||||||
|
|
||||||
|
// Fetch all tickets
|
||||||
|
const { data: tickets, error: fetchError } = await supabase
|
||||||
|
.from('contact_submissions')
|
||||||
|
.select('id, ticket_number, admin_notes, merged_ticket_numbers')
|
||||||
|
.in('id', allTicketIds);
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
if (!tickets || tickets.length !== allTicketIds.length) {
|
||||||
|
throw new Error('One or more tickets not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryTicket = tickets.find(t => t.id === primaryTicketId);
|
||||||
|
const mergeTickets = tickets.filter(t => mergeTicketIds.includes(t.id));
|
||||||
|
|
||||||
|
if (!primaryTicket) {
|
||||||
|
throw new Error('Primary ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any ticket already has merged_ticket_numbers
|
||||||
|
const alreadyMerged = tickets.find(t =>
|
||||||
|
t.merged_ticket_numbers && t.merged_ticket_numbers.length > 0
|
||||||
|
);
|
||||||
|
if (alreadyMerged) {
|
||||||
|
throw new Error(`Ticket ${alreadyMerged.ticket_number} has already been used in a merge`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpanEvent(span, 'tickets_validated', {
|
||||||
|
primaryTicket: primaryTicket.ticket_number,
|
||||||
|
mergeTicketCount: mergeTickets.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Move all email threads to primary ticket
|
||||||
|
addSpanEvent(span, 'moving_email_threads', {
|
||||||
|
fromTickets: mergeTickets.map(t => t.ticket_number)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: movedThreads, error: moveError } = await supabase
|
||||||
|
.from('contact_email_threads')
|
||||||
|
.update({ submission_id: primaryTicketId })
|
||||||
|
.in('submission_id', mergeTicketIds)
|
||||||
|
.select('id');
|
||||||
|
|
||||||
|
if (moveError) throw moveError;
|
||||||
|
|
||||||
|
const threadsMovedCount = movedThreads?.length || 0;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'threads_moved', { threadsMovedCount });
|
||||||
|
|
||||||
|
if (threadsMovedCount === 0) {
|
||||||
|
addSpanEvent(span, 'no_threads_found', { mergeTicketIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Consolidate admin notes
|
||||||
|
let consolidatedNotes = primaryTicket.admin_notes || '';
|
||||||
|
|
||||||
|
for (const ticket of mergeTickets) {
|
||||||
|
if (ticket.admin_notes) {
|
||||||
|
consolidatedNotes = consolidatedNotes.trim()
|
||||||
|
? `${consolidatedNotes}\n\n${ticket.admin_notes}`
|
||||||
|
: ticket.admin_notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Recalculate metadata from consolidated threads
|
||||||
|
const { data: threadStats, error: statsError } = await supabase
|
||||||
|
.from('contact_email_threads')
|
||||||
|
.select('direction, created_at')
|
||||||
|
.eq('submission_id', primaryTicketId);
|
||||||
|
|
||||||
|
if (statsError) throw statsError;
|
||||||
|
|
||||||
|
const outboundCount = threadStats?.filter(t => t.direction === 'outbound').length || 0;
|
||||||
|
const lastAdminResponse = threadStats
|
||||||
|
?.filter(t => t.direction === 'outbound')
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at;
|
||||||
|
const lastUserResponse = threadStats
|
||||||
|
?.filter(t => t.direction === 'inbound')
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'metadata_recalculated', {
|
||||||
|
outboundCount,
|
||||||
|
lastAdminResponse,
|
||||||
|
lastUserResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get merged ticket numbers
|
||||||
|
const mergedTicketNumbers = mergeTickets.map(t => t.ticket_number);
|
||||||
|
|
||||||
|
// Step 4: Update primary ticket with consolidated data
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('contact_submissions')
|
||||||
|
.update({
|
||||||
|
admin_notes: consolidatedNotes,
|
||||||
|
response_count: outboundCount,
|
||||||
|
last_admin_response_at: lastAdminResponse || null,
|
||||||
|
merged_ticket_numbers: [
|
||||||
|
...(primaryTicket.merged_ticket_numbers || []),
|
||||||
|
...mergedTicketNumbers
|
||||||
|
],
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', primaryTicketId);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'primary_ticket_updated', { primaryTicket: primaryTicket.ticket_number });
|
||||||
|
|
||||||
|
// Step 5: Delete merged tickets
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('contact_submissions')
|
||||||
|
.delete()
|
||||||
|
.in('id', mergeTicketIds);
|
||||||
|
|
||||||
|
if (deleteError) throw deleteError;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'merged_tickets_deleted', { count: mergeTicketIds.length });
|
||||||
|
|
||||||
|
// Step 6: Audit log
|
||||||
|
const { error: auditError } = await supabase.from('admin_audit_log').insert({
|
||||||
|
admin_user_id: user.id,
|
||||||
|
target_user_id: user.id,
|
||||||
|
action: 'merge_contact_tickets',
|
||||||
|
details: {
|
||||||
|
primary_ticket_id: primaryTicketId,
|
||||||
|
primary_ticket_number: primaryTicket.ticket_number,
|
||||||
|
merged_ticket_ids: mergeTicketIds,
|
||||||
|
merged_ticket_numbers: mergedTicketNumbers,
|
||||||
|
merge_reason: mergeReason || null,
|
||||||
|
threads_moved: threadsMovedCount,
|
||||||
|
merged_count: mergeTickets.length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditError) {
|
||||||
|
addSpanEvent(span, 'audit_log_failed', { error: auditError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpanEvent(span, 'merge_completed', {
|
||||||
|
primaryTicket: primaryTicket.ticket_number,
|
||||||
|
mergedCount: mergeTickets.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: MergeTicketsResponse = {
|
||||||
|
success: true,
|
||||||
|
primaryTicketNumber: primaryTicket.ticket_number,
|
||||||
|
mergedCount: mergeTickets.length,
|
||||||
|
threadsConsolidated: threadsMovedCount,
|
||||||
|
deletedTickets: mergedTicketNumbers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'merge-contact-tickets',
|
||||||
|
requireAuth: true,
|
||||||
|
requiredRoles: ['superuser', 'admin', 'moderator'],
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
logRequests: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
import { withEdgeRetry } from '../_shared/retryHelper.ts';
|
import { withEdgeRetry } from '../_shared/retryHelper.ts';
|
||||||
|
|
||||||
interface NotificationPayload {
|
interface NotificationPayload {
|
||||||
@@ -15,204 +15,166 @@ interface NotificationPayload {
|
|||||||
entityPreview: string;
|
entityPreview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
serve(async (req) => {
|
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
|
||||||
if (req.method === 'OPTIONS') {
|
const payload: NotificationPayload = await req.json();
|
||||||
return new Response(null, { headers: corsHeaders });
|
|
||||||
|
addSpanEvent(span, 'processing_report_notification', {
|
||||||
|
reportId: payload.reportId,
|
||||||
|
reportType: payload.reportType,
|
||||||
|
reportedEntityType: payload.reportedEntityType
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate relative time
|
||||||
|
const reportedAt = new Date(payload.reportedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - reportedAt.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
let relativeTime: string;
|
||||||
|
if (diffMins < 1) {
|
||||||
|
relativeTime = 'just now';
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
relativeTime = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||||
|
} else {
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
relativeTime = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracking = startRequest('notify-moderators-report');
|
// Determine priority based on report type and age
|
||||||
|
let priority: string;
|
||||||
|
const criticalTypes = ['harassment', 'offensive'];
|
||||||
|
const isUrgent = diffMins < 5;
|
||||||
|
|
||||||
|
if (criticalTypes.includes(payload.reportType) || isUrgent) {
|
||||||
|
priority = 'high';
|
||||||
|
} else if (diffMins < 30) {
|
||||||
|
priority = 'medium';
|
||||||
|
} else {
|
||||||
|
priority = 'low';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Fetch the workflow ID for report alerts
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
const { data: template, error: templateError } = await supabase
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
.from('notification_templates')
|
||||||
|
.select('workflow_id')
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
.eq('workflow_id', 'report-alert')
|
||||||
|
.eq('is_active', true)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
const payload: NotificationPayload = await req.json();
|
if (templateError) {
|
||||||
|
addSpanEvent(span, 'workflow_fetch_failed', { error: templateError.message });
|
||||||
edgeLogger.info('Processing report notification', {
|
throw new Error(`Failed to fetch workflow: ${templateError.message}`);
|
||||||
action: 'notify_moderators_report',
|
}
|
||||||
reportId: payload.reportId,
|
|
||||||
reportType: payload.reportType,
|
|
||||||
reportedEntityType: payload.reportedEntityType,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate relative time
|
|
||||||
const reportedAt = new Date(payload.reportedAt);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - reportedAt.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
|
|
||||||
let relativeTime: string;
|
|
||||||
if (diffMins < 1) {
|
|
||||||
relativeTime = 'just now';
|
|
||||||
} else if (diffMins < 60) {
|
|
||||||
relativeTime = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
|
||||||
} else {
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
relativeTime = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine priority based on report type and age
|
|
||||||
let priority: string;
|
|
||||||
const criticalTypes = ['harassment', 'offensive'];
|
|
||||||
const isUrgent = diffMins < 5;
|
|
||||||
|
|
||||||
if (criticalTypes.includes(payload.reportType) || isUrgent) {
|
|
||||||
priority = 'high';
|
|
||||||
} else if (diffMins < 30) {
|
|
||||||
priority = 'medium';
|
|
||||||
} else {
|
|
||||||
priority = 'low';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the workflow ID for report alerts
|
|
||||||
const { data: template, error: templateError } = await supabase
|
|
||||||
.from('notification_templates')
|
|
||||||
.select('workflow_id')
|
|
||||||
.eq('workflow_id', 'report-alert')
|
|
||||||
.eq('is_active', true)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (templateError) {
|
|
||||||
edgeLogger.error('Error fetching workflow', { action: 'notify_moderators_report', requestId: tracking.requestId, error: templateError });
|
|
||||||
throw new Error(`Failed to fetch workflow: ${templateError.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
edgeLogger.warn('No active report-alert workflow found', { action: 'notify_moderators_report', requestId: tracking.requestId });
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'No active report-alert workflow configured',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
status: 400,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch reported entity name
|
|
||||||
let reportedEntityName = 'Unknown';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (payload.reportedEntityType === 'review') {
|
|
||||||
const { data: review } = await supabase
|
|
||||||
.from('reviews')
|
|
||||||
.select('ride:rides(name), park:parks(name)')
|
|
||||||
.eq('id', payload.reportedEntityId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
reportedEntityName = review?.ride?.name || review?.park?.name || 'Review';
|
|
||||||
} else if (payload.reportedEntityType === 'profile') {
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('display_name, username')
|
|
||||||
.eq('user_id', payload.reportedEntityId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
reportedEntityName = profile?.display_name || profile?.username || 'User Profile';
|
|
||||||
} else if (payload.reportedEntityType === 'content_submission') {
|
|
||||||
// Query submission_metadata table for the name instead of dropped content JSONB column
|
|
||||||
const { data: metadata } = await supabase
|
|
||||||
.from('submission_metadata')
|
|
||||||
.select('metadata_value')
|
|
||||||
.eq('submission_id', payload.reportedEntityId)
|
|
||||||
.eq('metadata_key', 'name')
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
reportedEntityName = metadata?.metadata_value || 'Submission';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
edgeLogger.warn('Could not fetch entity name', { action: 'notify_moderators_report', requestId: tracking.requestId, error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build enhanced notification payload
|
|
||||||
const notificationPayload = {
|
|
||||||
baseUrl: 'https://www.thrillwiki.com',
|
|
||||||
reportId: payload.reportId,
|
|
||||||
reportType: payload.reportType,
|
|
||||||
reportedEntityType: payload.reportedEntityType,
|
|
||||||
reportedEntityId: payload.reportedEntityId,
|
|
||||||
reporterName: payload.reporterName,
|
|
||||||
reason: payload.reason,
|
|
||||||
entityPreview: payload.entityPreview,
|
|
||||||
reportedEntityName,
|
|
||||||
reportedAt: payload.reportedAt,
|
|
||||||
relativeTime,
|
|
||||||
priority,
|
|
||||||
};
|
|
||||||
|
|
||||||
edgeLogger.info('Triggering notification with payload', { action: 'notify_moderators_report', requestId: tracking.requestId });
|
|
||||||
|
|
||||||
// Invoke the trigger-notification function with retry
|
|
||||||
const result = await withEdgeRetry(
|
|
||||||
async () => {
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
|
||||||
'trigger-notification',
|
|
||||||
{
|
|
||||||
body: {
|
|
||||||
workflowId: template.workflow_id,
|
|
||||||
topicKey: 'moderation-reports',
|
|
||||||
payload: notificationPayload,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const enhancedError = new Error(error.message || 'Notification trigger failed');
|
|
||||||
(enhancedError as any).status = error.status;
|
|
||||||
throw enhancedError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
{ maxAttempts: 3, baseDelay: 1000 },
|
|
||||||
tracking.requestId,
|
|
||||||
'trigger-report-notification'
|
|
||||||
);
|
|
||||||
|
|
||||||
edgeLogger.info('Notification triggered successfully', { action: 'notify_moderators_report', requestId: tracking.requestId, result });
|
|
||||||
|
|
||||||
endRequest(tracking, 200);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
transactionId: result?.transactionId,
|
|
||||||
payload: notificationPayload,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
edgeLogger.error('Error in notify-moderators-report', { action: 'notify_moderators_report', requestId: tracking.requestId, error: error.message });
|
|
||||||
|
|
||||||
endRequest(tracking, 500, error.message);
|
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
addSpanEvent(span, 'no_active_workflow', {});
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: 'No active report-alert workflow configured',
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
}),
|
||||||
{
|
{ status: 400 }
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 500,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Fetch reported entity name
|
||||||
|
let reportedEntityName = 'Unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (payload.reportedEntityType === 'review') {
|
||||||
|
const { data: review } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('ride:rides(name), park:parks(name)')
|
||||||
|
.eq('id', payload.reportedEntityId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
reportedEntityName = review?.ride?.name || review?.park?.name || 'Review';
|
||||||
|
} else if (payload.reportedEntityType === 'profile') {
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('display_name, username')
|
||||||
|
.eq('user_id', payload.reportedEntityId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
reportedEntityName = profile?.display_name || profile?.username || 'User Profile';
|
||||||
|
} else if (payload.reportedEntityType === 'content_submission') {
|
||||||
|
const { data: metadata } = await supabase
|
||||||
|
.from('submission_metadata')
|
||||||
|
.select('metadata_value')
|
||||||
|
.eq('submission_id', payload.reportedEntityId)
|
||||||
|
.eq('metadata_key', 'name')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
reportedEntityName = metadata?.metadata_value || 'Submission';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addSpanEvent(span, 'entity_name_fetch_failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build enhanced notification payload
|
||||||
|
const notificationPayload = {
|
||||||
|
baseUrl: 'https://www.thrillwiki.com',
|
||||||
|
reportId: payload.reportId,
|
||||||
|
reportType: payload.reportType,
|
||||||
|
reportedEntityType: payload.reportedEntityType,
|
||||||
|
reportedEntityId: payload.reportedEntityId,
|
||||||
|
reporterName: payload.reporterName,
|
||||||
|
reason: payload.reason,
|
||||||
|
entityPreview: payload.entityPreview,
|
||||||
|
reportedEntityName,
|
||||||
|
reportedAt: payload.reportedAt,
|
||||||
|
relativeTime,
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
|
||||||
|
addSpanEvent(span, 'triggering_notification', {
|
||||||
|
workflowId: template.workflow_id,
|
||||||
|
priority
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invoke the trigger-notification function with retry
|
||||||
|
const result = await withEdgeRetry(
|
||||||
|
async () => {
|
||||||
|
const { data, error } = await supabase.functions.invoke(
|
||||||
|
'trigger-notification',
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
workflowId: template.workflow_id,
|
||||||
|
topicKey: 'moderation-reports',
|
||||||
|
payload: notificationPayload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const enhancedError = new Error(error.message || 'Notification trigger failed');
|
||||||
|
(enhancedError as any).status = error.status;
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 3, baseDelay: 1000 },
|
||||||
|
requestId,
|
||||||
|
'trigger-report-notification'
|
||||||
|
);
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_sent', { transactionId: result?.transactionId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
transactionId: result?.transactionId,
|
||||||
|
payload: notificationPayload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'notify-moderators-report',
|
||||||
|
requireAuth: false,
|
||||||
|
useServiceRole: true,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
logRequests: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
import { withEdgeRetry } from '../_shared/retryHelper.ts';
|
import { withEdgeRetry } from '../_shared/retryHelper.ts';
|
||||||
|
|
||||||
interface NotificationPayload {
|
interface NotificationPayload {
|
||||||
@@ -16,270 +16,177 @@ interface NotificationPayload {
|
|||||||
is_escalated: boolean;
|
is_escalated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
serve(async (req) => {
|
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
|
||||||
const tracking = startRequest();
|
const payload: NotificationPayload = await req.json();
|
||||||
|
const {
|
||||||
|
submission_id,
|
||||||
|
submission_type,
|
||||||
|
submitter_name,
|
||||||
|
action,
|
||||||
|
content_preview,
|
||||||
|
submitted_at,
|
||||||
|
has_photos,
|
||||||
|
item_count,
|
||||||
|
is_escalated
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'processing_moderator_notification', {
|
||||||
|
submission_id,
|
||||||
|
submission_type
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate relative time and priority
|
||||||
|
const submittedDate = new Date(submitted_at);
|
||||||
|
const now = new Date();
|
||||||
|
const waitTimeMs = now.getTime() - submittedDate.getTime();
|
||||||
|
const waitTimeHours = waitTimeMs / (1000 * 60 * 60);
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
// Format relative time
|
||||||
return new Response(null, {
|
const relativeTime = (() => {
|
||||||
headers: {
|
const minutes = Math.floor(waitTimeMs / (1000 * 60));
|
||||||
...corsHeaders,
|
const hours = Math.floor(waitTimeMs / (1000 * 60 * 60));
|
||||||
'X-Request-ID': tracking.requestId
|
const days = Math.floor(waitTimeMs / (1000 * 60 * 60 * 24));
|
||||||
}
|
|
||||||
});
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||||
|
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||||
|
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Determine priority based on wait time
|
||||||
|
const priority = waitTimeHours >= 24 ? 'urgent' : 'normal';
|
||||||
|
|
||||||
|
// Get the moderation-alert workflow
|
||||||
|
const { data: workflow, error: workflowError } = await supabase
|
||||||
|
.from('notification_templates')
|
||||||
|
.select('workflow_id')
|
||||||
|
.eq('category', 'moderation')
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (workflowError || !workflow) {
|
||||||
|
addSpanEvent(span, 'workflow_fetch_failed', { error: workflowError?.message });
|
||||||
|
throw new Error('Workflow not found or not active');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Generate idempotency key for duplicate prevention
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
const { data: keyData, error: keyError } = await supabase
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
.rpc('generate_notification_idempotency_key', {
|
||||||
|
p_notification_type: 'moderation_submission',
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
p_entity_id: submission_id,
|
||||||
|
p_recipient_id: '00000000-0000-0000-0000-000000000000',
|
||||||
const payload: NotificationPayload = await req.json();
|
p_event_data: { submission_type, action }
|
||||||
const {
|
|
||||||
submission_id,
|
|
||||||
submission_type,
|
|
||||||
submitter_name,
|
|
||||||
action,
|
|
||||||
content_preview,
|
|
||||||
submitted_at,
|
|
||||||
has_photos,
|
|
||||||
item_count,
|
|
||||||
is_escalated
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
edgeLogger.info('Notifying moderators about submission via topic', {
|
|
||||||
action: 'notify_moderators',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
submission_id,
|
|
||||||
submission_type,
|
|
||||||
content_preview
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate relative time and priority
|
const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`;
|
||||||
const submittedDate = new Date(submitted_at);
|
|
||||||
const now = new Date();
|
|
||||||
const waitTimeMs = now.getTime() - submittedDate.getTime();
|
|
||||||
const waitTimeHours = waitTimeMs / (1000 * 60 * 60);
|
|
||||||
|
|
||||||
// Format relative time
|
|
||||||
const relativeTime = (() => {
|
|
||||||
const minutes = Math.floor(waitTimeMs / (1000 * 60));
|
|
||||||
const hours = Math.floor(waitTimeMs / (1000 * 60 * 60));
|
|
||||||
const days = Math.floor(waitTimeMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (minutes < 1) return 'just now';
|
|
||||||
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
||||||
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
||||||
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Determine priority based on wait time
|
|
||||||
const priority = waitTimeHours >= 24 ? 'urgent' : 'normal';
|
|
||||||
|
|
||||||
// Get the moderation-alert workflow
|
// Check for duplicate within 24h window
|
||||||
const { data: workflow, error: workflowError } = await supabase
|
const { data: existingLog, error: logCheckError } = await supabase
|
||||||
.from('notification_templates')
|
.from('notification_logs')
|
||||||
.select('workflow_id')
|
.select('id')
|
||||||
.eq('category', 'moderation')
|
.eq('idempotency_key', idempotencyKey)
|
||||||
.eq('is_active', true)
|
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
if (workflowError || !workflow) {
|
if (existingLog) {
|
||||||
const duration = endRequest(tracking);
|
// Duplicate detected
|
||||||
edgeLogger.error('Error fetching workflow', {
|
await supabase.from('notification_logs').update({
|
||||||
action: 'notify_moderators',
|
is_duplicate: true
|
||||||
requestId: tracking.requestId,
|
}).eq('id', existingLog.id);
|
||||||
duration,
|
|
||||||
error: workflowError
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Workflow not found or not active',
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 500,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate idempotency key for duplicate prevention
|
addSpanEvent(span, 'duplicate_notification_prevented', {
|
||||||
const { data: keyData, error: keyError } = await supabase
|
idempotencyKey,
|
||||||
.rpc('generate_notification_idempotency_key', {
|
submission_id
|
||||||
p_notification_type: 'moderation_submission',
|
});
|
||||||
p_entity_id: submission_id,
|
|
||||||
p_recipient_id: '00000000-0000-0000-0000-000000000000', // Topic-based, use placeholder
|
|
||||||
p_event_data: { submission_type, action }
|
|
||||||
});
|
|
||||||
|
|
||||||
const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`;
|
return {
|
||||||
|
success: true,
|
||||||
// Check for duplicate within 24h window
|
message: 'Duplicate notification prevented',
|
||||||
const { data: existingLog, error: logCheckError } = await supabase
|
idempotencyKey,
|
||||||
.from('notification_logs')
|
|
||||||
.select('id')
|
|
||||||
.eq('idempotency_key', idempotencyKey)
|
|
||||||
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingLog) {
|
|
||||||
// Duplicate detected - log and skip
|
|
||||||
await supabase.from('notification_logs').update({
|
|
||||||
is_duplicate: true
|
|
||||||
}).eq('id', existingLog.id);
|
|
||||||
|
|
||||||
edgeLogger.info('Duplicate notification prevented', {
|
|
||||||
action: 'notify_moderators',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
submission_id
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: 'Duplicate notification prevented',
|
|
||||||
idempotencyKey,
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare enhanced notification payload
|
|
||||||
const notificationPayload = {
|
|
||||||
baseUrl: 'https://www.thrillwiki.com',
|
|
||||||
// Basic info
|
|
||||||
itemType: submission_type,
|
|
||||||
submitterName: submitter_name,
|
|
||||||
submissionId: submission_id,
|
|
||||||
action: action || 'create',
|
|
||||||
moderationUrl: 'https://www.thrillwiki.com/admin/moderation',
|
|
||||||
|
|
||||||
// Enhanced content
|
|
||||||
contentPreview: content_preview,
|
|
||||||
|
|
||||||
// Timing information
|
|
||||||
submittedAt: submitted_at,
|
|
||||||
relativeTime: relativeTime,
|
|
||||||
priority: priority,
|
|
||||||
|
|
||||||
// Additional metadata
|
|
||||||
hasPhotos: has_photos,
|
|
||||||
itemCount: item_count,
|
|
||||||
isEscalated: is_escalated,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send ONE notification to the moderation-submissions topic with retry
|
|
||||||
// All subscribers (moderators) will receive it automatically
|
|
||||||
const data = await withEdgeRetry(
|
|
||||||
async () => {
|
|
||||||
const { data: result, error } = await supabase.functions.invoke('trigger-notification', {
|
|
||||||
body: {
|
|
||||||
workflowId: workflow.workflow_id,
|
|
||||||
topicKey: 'moderation-submissions',
|
|
||||||
payload: notificationPayload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const enhancedError = new Error(error.message || 'Notification trigger failed');
|
|
||||||
(enhancedError as any).status = error.status;
|
|
||||||
throw enhancedError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
{ maxAttempts: 3, baseDelay: 1000 },
|
|
||||||
tracking.requestId,
|
|
||||||
'trigger-submission-notification'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log notification in notification_logs with idempotency key
|
|
||||||
const { error: logError } = await supabase.from('notification_logs').insert({
|
|
||||||
user_id: '00000000-0000-0000-0000-000000000000', // Topic-based
|
|
||||||
notification_type: 'moderation_submission',
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
is_duplicate: false,
|
|
||||||
metadata: {
|
|
||||||
submission_id,
|
|
||||||
submission_type,
|
|
||||||
transaction_id: data?.transactionId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (logError) {
|
|
||||||
// Non-blocking - notification was sent successfully, log failure shouldn't fail the request
|
|
||||||
edgeLogger.warn('Failed to log notification in notification_logs', {
|
|
||||||
action: 'notify_moderators',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
error: logError.message,
|
|
||||||
submissionId: submission_id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Successfully notified all moderators via topic', {
|
|
||||||
action: 'notify_moderators',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
traceId: tracking.traceId,
|
|
||||||
duration,
|
|
||||||
transactionId: data?.transactionId
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: 'Moderator notifications sent via topic',
|
|
||||||
topicKey: 'moderation-submissions',
|
|
||||||
transactionId: data?.transactionId,
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Error in notify-moderators-submission', {
|
|
||||||
action: 'notify_moderators',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 500,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Prepare enhanced notification payload
|
||||||
|
const notificationPayload = {
|
||||||
|
baseUrl: 'https://www.thrillwiki.com',
|
||||||
|
itemType: submission_type,
|
||||||
|
submitterName: submitter_name,
|
||||||
|
submissionId: submission_id,
|
||||||
|
action: action || 'create',
|
||||||
|
moderationUrl: 'https://www.thrillwiki.com/admin/moderation',
|
||||||
|
contentPreview: content_preview,
|
||||||
|
submittedAt: submitted_at,
|
||||||
|
relativeTime: relativeTime,
|
||||||
|
priority: priority,
|
||||||
|
hasPhotos: has_photos,
|
||||||
|
itemCount: item_count,
|
||||||
|
isEscalated: is_escalated,
|
||||||
|
};
|
||||||
|
|
||||||
|
addSpanEvent(span, 'triggering_notification', {
|
||||||
|
workflowId: workflow.workflow_id,
|
||||||
|
priority
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send ONE notification to the moderation-submissions topic with retry
|
||||||
|
const data = await withEdgeRetry(
|
||||||
|
async () => {
|
||||||
|
const { data: result, error } = await supabase.functions.invoke('trigger-notification', {
|
||||||
|
body: {
|
||||||
|
workflowId: workflow.workflow_id,
|
||||||
|
topicKey: 'moderation-submissions',
|
||||||
|
payload: notificationPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const enhancedError = new Error(error.message || 'Notification trigger failed');
|
||||||
|
(enhancedError as any).status = error.status;
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 3, baseDelay: 1000 },
|
||||||
|
requestId,
|
||||||
|
'trigger-submission-notification'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log notification with idempotency key
|
||||||
|
const { error: logError } = await supabase.from('notification_logs').insert({
|
||||||
|
user_id: '00000000-0000-0000-0000-000000000000',
|
||||||
|
notification_type: 'moderation_submission',
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
is_duplicate: false,
|
||||||
|
metadata: {
|
||||||
|
submission_id,
|
||||||
|
submission_type,
|
||||||
|
transaction_id: data?.transactionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logError) {
|
||||||
|
addSpanEvent(span, 'log_insertion_failed', { error: logError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_sent', {
|
||||||
|
transactionId: data?.transactionId,
|
||||||
|
topicKey: 'moderation-submissions'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Moderator notifications sent via topic',
|
||||||
|
topicKey: 'moderation-submissions',
|
||||||
|
transactionId: data?.transactionId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'notify-moderators-submission',
|
||||||
|
requireAuth: false,
|
||||||
|
useServiceRole: true,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
logRequests: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
|
||||||
interface RequestBody {
|
interface RequestBody {
|
||||||
submission_id: string;
|
submission_id: string;
|
||||||
@@ -81,203 +81,151 @@ async function constructEntityURL(
|
|||||||
return `${baseURL}`;
|
return `${baseURL}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
serve(async (req) => {
|
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
|
||||||
if (req.method === 'OPTIONS') {
|
const { submission_id, user_id, submission_type, status, reviewer_notes } = await req.json() as RequestBody;
|
||||||
return new Response(null, { headers: corsHeaders });
|
|
||||||
|
addSpanEvent(span, 'notification_request', {
|
||||||
|
submissionId: submission_id,
|
||||||
|
userId: user_id,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch submission items to get entity data
|
||||||
|
const { data: items, error: itemsError } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.select('item_data')
|
||||||
|
.eq('submission_id', submission_id)
|
||||||
|
.order('order_index', { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (itemsError) {
|
||||||
|
throw new Error(`Failed to fetch submission items: ${itemsError.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracking = startRequest('notify-user-submission-status');
|
if (!items || !items.item_data) {
|
||||||
|
throw new Error('No submission items found');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Extract entity data
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
const entityName = items.item_data.name || 'your submission';
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
const entityType = submission_type.replace('_', ' ');
|
||||||
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
// Construct entity URL
|
||||||
|
const entityURL = await constructEntityURL(supabase, submission_type, items.item_data);
|
||||||
|
|
||||||
const { submission_id, user_id, submission_type, status, reviewer_notes } = await req.json() as RequestBody;
|
// Determine workflow and build payload based on status
|
||||||
|
const workflowId = status === 'approved' ? 'submission-approved' : 'submission-rejected';
|
||||||
// Fetch submission items to get entity data
|
|
||||||
const { data: items, error: itemsError } = await supabase
|
let payload: Record<string, string>;
|
||||||
.from('submission_items')
|
|
||||||
.select('item_data')
|
if (status === 'approved') {
|
||||||
.eq('submission_id', submission_id)
|
payload = {
|
||||||
.order('order_index', { ascending: true })
|
baseUrl: 'https://www.thrillwiki.com',
|
||||||
.limit(1)
|
entityType,
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (itemsError) {
|
|
||||||
throw new Error(`Failed to fetch submission items: ${itemsError.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items || !items.item_data) {
|
|
||||||
throw new Error('No submission items found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract entity data
|
|
||||||
const entityName = items.item_data.name || 'your submission';
|
|
||||||
const entityType = submission_type.replace('_', ' ');
|
|
||||||
|
|
||||||
// Construct entity URL
|
|
||||||
const entityURL = await constructEntityURL(supabase, submission_type, items.item_data);
|
|
||||||
|
|
||||||
// Determine workflow and build payload based on status
|
|
||||||
const workflowId = status === 'approved' ? 'submission-approved' : 'submission-rejected';
|
|
||||||
|
|
||||||
let payload: Record<string, string>;
|
|
||||||
|
|
||||||
if (status === 'approved') {
|
|
||||||
// Approval payload
|
|
||||||
payload = {
|
|
||||||
baseUrl: 'https://www.thrillwiki.com',
|
|
||||||
entityType,
|
|
||||||
entityName,
|
|
||||||
submissionId: submission_id,
|
|
||||||
entityURL,
|
|
||||||
moderationNotes: reviewer_notes || '',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Rejection payload
|
|
||||||
payload = {
|
|
||||||
baseUrl: 'https://www.thrillwiki.com',
|
|
||||||
rejectionReason: reviewer_notes || 'No reason provided',
|
|
||||||
entityType,
|
|
||||||
entityName,
|
|
||||||
entityURL,
|
|
||||||
actualStatus: 'rejected',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate idempotency key for duplicate prevention
|
|
||||||
const { data: keyData, error: keyError } = await supabase
|
|
||||||
.rpc('generate_notification_idempotency_key', {
|
|
||||||
p_notification_type: `submission_${status}`,
|
|
||||||
p_entity_id: submission_id,
|
|
||||||
p_recipient_id: user_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
|
|
||||||
|
|
||||||
// Check for duplicate within 24h window
|
|
||||||
const { data: existingLog, error: logCheckError } = await supabase
|
|
||||||
.from('notification_logs')
|
|
||||||
.select('id')
|
|
||||||
.eq('user_id', user_id)
|
|
||||||
.eq('idempotency_key', idempotencyKey)
|
|
||||||
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (existingLog) {
|
|
||||||
// Duplicate detected - log and skip
|
|
||||||
await supabase.from('notification_logs').update({
|
|
||||||
is_duplicate: true
|
|
||||||
}).eq('id', existingLog.id);
|
|
||||||
|
|
||||||
edgeLogger.info('Duplicate notification prevented', {
|
|
||||||
action: 'notify_user_submission_status',
|
|
||||||
userId: user_id,
|
|
||||||
idempotencyKey,
|
|
||||||
submissionId: submission_id,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
endRequest(tracking, 200);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
message: 'Duplicate notification prevented',
|
|
||||||
idempotencyKey,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Sending notification to user', {
|
|
||||||
action: 'notify_user_submission_status',
|
|
||||||
userId: user_id,
|
|
||||||
workflowId,
|
|
||||||
entityName,
|
entityName,
|
||||||
status,
|
submissionId: submission_id,
|
||||||
idempotencyKey,
|
entityURL,
|
||||||
requestId: tracking.requestId
|
moderationNotes: reviewer_notes || '',
|
||||||
});
|
};
|
||||||
|
} else {
|
||||||
// Call trigger-notification function
|
payload = {
|
||||||
const { data: notificationResult, error: notificationError } = await supabase.functions.invoke(
|
baseUrl: 'https://www.thrillwiki.com',
|
||||||
'trigger-notification',
|
rejectionReason: reviewer_notes || 'No reason provided',
|
||||||
{
|
entityType,
|
||||||
body: {
|
entityName,
|
||||||
workflowId,
|
entityURL,
|
||||||
subscriberId: user_id,
|
actualStatus: 'rejected',
|
||||||
payload,
|
};
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (notificationError) {
|
|
||||||
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log notification in notification_logs with idempotency key
|
|
||||||
await supabase.from('notification_logs').insert({
|
|
||||||
user_id,
|
|
||||||
notification_type: `submission_${status}`,
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
is_duplicate: false,
|
|
||||||
metadata: {
|
|
||||||
submission_id,
|
|
||||||
submission_type,
|
|
||||||
transaction_id: notificationResult?.transactionId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
edgeLogger.info('User notification sent successfully', { action: 'notify_user_submission_status', requestId: tracking.requestId, result: notificationResult });
|
|
||||||
|
|
||||||
endRequest(tracking, 200);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
transactionId: notificationResult?.transactionId,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
||||||
edgeLogger.error('Error notifying user about submission status', { action: 'notify_user_submission_status', requestId: tracking.requestId, error: errorMessage });
|
|
||||||
|
|
||||||
endRequest(tracking, 500, errorMessage);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 500,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Generate idempotency key for duplicate prevention
|
||||||
|
const { data: keyData, error: keyError } = await supabase
|
||||||
|
.rpc('generate_notification_idempotency_key', {
|
||||||
|
p_notification_type: `submission_${status}`,
|
||||||
|
p_entity_id: submission_id,
|
||||||
|
p_recipient_id: user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
|
||||||
|
|
||||||
|
// Check for duplicate within 24h window
|
||||||
|
const { data: existingLog, error: logCheckError } = await supabase
|
||||||
|
.from('notification_logs')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user_id)
|
||||||
|
.eq('idempotency_key', idempotencyKey)
|
||||||
|
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existingLog) {
|
||||||
|
// Duplicate detected - log and skip
|
||||||
|
await supabase.from('notification_logs').update({
|
||||||
|
is_duplicate: true
|
||||||
|
}).eq('id', existingLog.id);
|
||||||
|
|
||||||
|
addSpanEvent(span, 'duplicate_notification_prevented', {
|
||||||
|
idempotencyKey,
|
||||||
|
submissionId: submission_id
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Duplicate notification prevented',
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpanEvent(span, 'sending_notification', {
|
||||||
|
workflowId,
|
||||||
|
entityName,
|
||||||
|
idempotencyKey
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call trigger-notification function
|
||||||
|
const { data: notificationResult, error: notificationError } = await supabase.functions.invoke(
|
||||||
|
'trigger-notification',
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
workflowId,
|
||||||
|
subscriberId: user_id,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (notificationError) {
|
||||||
|
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log notification in notification_logs with idempotency key
|
||||||
|
await supabase.from('notification_logs').insert({
|
||||||
|
user_id,
|
||||||
|
notification_type: `submission_${status}`,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
is_duplicate: false,
|
||||||
|
metadata: {
|
||||||
|
submission_id,
|
||||||
|
submission_type,
|
||||||
|
transaction_id: notificationResult?.transactionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_sent', {
|
||||||
|
transactionId: notificationResult?.transactionId
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
transactionId: notificationResult?.transactionId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'notify-user-submission-status',
|
||||||
|
requireAuth: false,
|
||||||
|
useServiceRole: true,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
logRequests: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,106 +1,72 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger } from '../_shared/logger.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
|
||||||
// Simple request tracking
|
serve(createEdgeFunction({
|
||||||
const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() });
|
name: 'novu-webhook',
|
||||||
const endRequest = (tracking: { start: number }) => Date.now() - tracking.start;
|
requireAuth: false, // Webhooks don't use standard auth
|
||||||
|
useServiceRole: true, // Need service role to update notification_logs
|
||||||
|
corsHeaders,
|
||||||
|
}, async (req, { span, supabase, requestId }: EdgeFunctionContext) => {
|
||||||
|
const event = await req.json();
|
||||||
|
|
||||||
serve(async (req) => {
|
addSpanEvent(span, 'received_webhook_event', {
|
||||||
const tracking = startRequest();
|
eventType: event.type
|
||||||
|
});
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, { headers: corsHeaders });
|
// Handle different webhook events
|
||||||
|
switch (event.type) {
|
||||||
|
case 'notification.sent':
|
||||||
|
await handleNotificationSent(supabase, event, span);
|
||||||
|
break;
|
||||||
|
case 'notification.delivered':
|
||||||
|
await handleNotificationDelivered(supabase, event, span);
|
||||||
|
break;
|
||||||
|
case 'notification.read':
|
||||||
|
await handleNotificationRead(supabase, event, span);
|
||||||
|
break;
|
||||||
|
case 'notification.failed':
|
||||||
|
await handleNotificationFailed(supabase, event, span);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
addSpanEvent(span, 'unhandled_event_type', {
|
||||||
|
eventType: event.type
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return new Response(
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
JSON.stringify({ success: true, requestId }),
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
{
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
const event = await req.json();
|
},
|
||||||
|
status: 200,
|
||||||
edgeLogger.info('Received Novu webhook event', {
|
|
||||||
action: 'novu_webhook',
|
|
||||||
eventType: event.type,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle different webhook events
|
|
||||||
switch (event.type) {
|
|
||||||
case 'notification.sent':
|
|
||||||
await handleNotificationSent(supabase, event);
|
|
||||||
break;
|
|
||||||
case 'notification.delivered':
|
|
||||||
await handleNotificationDelivered(supabase, event);
|
|
||||||
break;
|
|
||||||
case 'notification.read':
|
|
||||||
await handleNotificationRead(supabase, event);
|
|
||||||
break;
|
|
||||||
case 'notification.failed':
|
|
||||||
await handleNotificationFailed(supabase, event);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
edgeLogger.warn('Unhandled Novu event type', {
|
|
||||||
action: 'novu_webhook',
|
|
||||||
eventType: event.type,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
async function handleNotificationSent(supabase: any, event: any, span: any) {
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ success: true, requestId: tracking.requestId }),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Error processing webhook', {
|
|
||||||
action: 'novu_webhook',
|
|
||||||
error: error?.message,
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
status: 500,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleNotificationSent(supabase: any, event: any) {
|
|
||||||
const { transactionId, channel } = event.data;
|
const { transactionId, channel } = event.data;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_sent_update', {
|
||||||
|
transactionId,
|
||||||
|
channel
|
||||||
|
});
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('notification_logs')
|
.from('notification_logs')
|
||||||
.update({ status: 'sent' })
|
.update({ status: 'sent' })
|
||||||
.eq('novu_transaction_id', transactionId);
|
.eq('novu_transaction_id', transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNotificationDelivered(supabase: any, event: any) {
|
async function handleNotificationDelivered(supabase: any, event: any, span: any) {
|
||||||
const { transactionId } = event.data;
|
const { transactionId } = event.data;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_delivered_update', {
|
||||||
|
transactionId
|
||||||
|
});
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('notification_logs')
|
.from('notification_logs')
|
||||||
.update({
|
.update({
|
||||||
@@ -110,9 +76,13 @@ async function handleNotificationDelivered(supabase: any, event: any) {
|
|||||||
.eq('novu_transaction_id', transactionId);
|
.eq('novu_transaction_id', transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNotificationRead(supabase: any, event: any) {
|
async function handleNotificationRead(supabase: any, event: any, span: any) {
|
||||||
const { transactionId } = event.data;
|
const { transactionId } = event.data;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_read_update', {
|
||||||
|
transactionId
|
||||||
|
});
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('notification_logs')
|
.from('notification_logs')
|
||||||
.update({
|
.update({
|
||||||
@@ -121,9 +91,14 @@ async function handleNotificationRead(supabase: any, event: any) {
|
|||||||
.eq('novu_transaction_id', transactionId);
|
.eq('novu_transaction_id', transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNotificationFailed(supabase: any, event: any) {
|
async function handleNotificationFailed(supabase: any, event: any, span: any) {
|
||||||
const { transactionId, error } = event.data;
|
const { transactionId, error } = event.data;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'notification_failed_update', {
|
||||||
|
transactionId,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('notification_logs')
|
.from('notification_logs')
|
||||||
.update({
|
.update({
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||||
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { edgeLogger } from '../_shared/logger.ts';
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
|
|
||||||
export default createEdgeFunction(
|
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => {
|
||||||
{
|
addSpanEvent(span, 'processing_expired_bans', { requestId });
|
||||||
name: 'process-expired-bans',
|
|
||||||
requireAuth: false,
|
|
||||||
},
|
|
||||||
async (req, context, supabase) => {
|
|
||||||
edgeLogger.info('Processing expired bans', {
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
@@ -23,25 +17,18 @@ export default createEdgeFunction(
|
|||||||
.lte('ban_expires_at', now);
|
.lte('ban_expires_at', now);
|
||||||
|
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
edgeLogger.error('Error fetching expired bans', {
|
addSpanEvent(span, 'error_fetching_expired_bans', { error: fetchError.message });
|
||||||
error: fetchError,
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
throw fetchError;
|
throw fetchError;
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Found expired bans to process', {
|
addSpanEvent(span, 'found_expired_bans', { count: expiredBans?.length || 0 });
|
||||||
count: expiredBans?.length || 0,
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unban users with expired bans
|
// Unban users with expired bans
|
||||||
const unbannedUsers: string[] = [];
|
const unbannedUsers: string[] = [];
|
||||||
for (const profile of expiredBans || []) {
|
for (const profile of expiredBans || []) {
|
||||||
edgeLogger.info('Unbanning user', {
|
addSpanEvent(span, 'unbanning_user', {
|
||||||
username: profile.username,
|
username: profile.username,
|
||||||
userId: profile.user_id,
|
userId: profile.user_id
|
||||||
requestId: context.requestId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error: unbanError } = await supabase
|
const { error: unbanError } = await supabase
|
||||||
@@ -54,10 +41,9 @@ export default createEdgeFunction(
|
|||||||
.eq('user_id', profile.user_id);
|
.eq('user_id', profile.user_id);
|
||||||
|
|
||||||
if (unbanError) {
|
if (unbanError) {
|
||||||
edgeLogger.error('Failed to unban user', {
|
addSpanEvent(span, 'failed_to_unban_user', {
|
||||||
username: profile.username,
|
username: profile.username,
|
||||||
error: unbanError,
|
error: unbanError.message
|
||||||
requestId: context.requestId
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -76,29 +62,28 @@ export default createEdgeFunction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (logError) {
|
if (logError) {
|
||||||
edgeLogger.error('Failed to log auto-unban', {
|
addSpanEvent(span, 'failed_to_log_auto_unban', {
|
||||||
username: profile.username,
|
username: profile.username,
|
||||||
error: logError,
|
error: logError.message
|
||||||
requestId: context.requestId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
unbannedUsers.push(profile.username);
|
unbannedUsers.push(profile.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Successfully unbanned users', {
|
addSpanEvent(span, 'successfully_unbanned_users', { count: unbannedUsers.length });
|
||||||
count: unbannedUsers.length,
|
|
||||||
requestId: context.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return {
|
||||||
JSON.stringify({
|
success: true,
|
||||||
success: true,
|
unbanned_count: unbannedUsers.length,
|
||||||
unbanned_count: unbannedUsers.length,
|
unbanned_users: unbannedUsers,
|
||||||
unbanned_users: unbannedUsers,
|
processed_at: now
|
||||||
processed_at: now
|
};
|
||||||
}),
|
};
|
||||||
{ headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
serve(createEdgeFunction({
|
||||||
}
|
name: 'process-expired-bans',
|
||||||
);
|
requireAuth: false,
|
||||||
|
corsHeaders,
|
||||||
|
enableTracing: true,
|
||||||
|
}, handler));
|
||||||
|
|||||||
@@ -1,614 +1,301 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.190.0/http/server.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
import { corsHeadersWithTracing } from '../_shared/cors.ts';
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
|
||||||
import {
|
import {
|
||||||
edgeLogger,
|
|
||||||
startSpan,
|
|
||||||
endSpan,
|
|
||||||
addSpanEvent,
|
addSpanEvent,
|
||||||
setSpanAttributes,
|
setSpanAttributes,
|
||||||
getSpanContext,
|
getSpanContext,
|
||||||
logSpan,
|
startSpan,
|
||||||
extractSpanContextFromHeaders,
|
endSpan,
|
||||||
type Span
|
|
||||||
} from '../_shared/logger.ts';
|
} from '../_shared/logger.ts';
|
||||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
import { toError } from '../_shared/errorFormatter.ts';
|
||||||
import {
|
import {
|
||||||
validateApprovalRequest,
|
validateApprovalRequest,
|
||||||
validateSubmissionItems,
|
|
||||||
getSubmissionTableName,
|
|
||||||
getMainTableName,
|
|
||||||
type ValidatedSubmissionItem
|
|
||||||
} from '../_shared/submissionValidation.ts';
|
} from '../_shared/submissionValidation.ts';
|
||||||
import { ValidationError } from '../_shared/typeValidation.ts';
|
import { ValidationError } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY');
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CRITICAL: Validate environment variables at startup
|
|
||||||
// ============================================================================
|
|
||||||
if (!SUPABASE_ANON_KEY) {
|
|
||||||
const errorMsg = 'CRITICAL: SUPABASE_ANON_KEY environment variable is not set!';
|
|
||||||
console.error(errorMsg, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
hasUrl: !!SUPABASE_URL,
|
|
||||||
url: SUPABASE_URL,
|
|
||||||
availableEnvVars: Object.keys(Deno.env.toObject()).filter(k =>
|
|
||||||
k.includes('SUPABASE') || k.includes('URL')
|
|
||||||
)
|
|
||||||
});
|
|
||||||
throw new Error('Missing required environment variable: SUPABASE_ANON_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Edge function initialized successfully', {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
function: 'process-selective-approval',
|
|
||||||
hasUrl: !!SUPABASE_URL,
|
|
||||||
hasKey: !!SUPABASE_ANON_KEY,
|
|
||||||
keyLength: SUPABASE_ANON_KEY.length
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ApprovalRequest {
|
|
||||||
submissionId: string;
|
|
||||||
itemIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main handler function
|
// Main handler function
|
||||||
const handler = async (req: Request) => {
|
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
|
||||||
// ============================================================================
|
const { supabase, user, span: rootSpan, requestId } = context;
|
||||||
// Log every incoming request immediately
|
|
||||||
// ============================================================================
|
// Early logging - confirms request reached handler
|
||||||
console.log('Request received', {
|
addSpanEvent(rootSpan, 'handler_entry', {
|
||||||
timestamp: new Date().toISOString(),
|
requestId,
|
||||||
method: req.method,
|
userId: user.id,
|
||||||
url: req.url,
|
timestamp: Date.now()
|
||||||
headers: {
|
|
||||||
authorization: req.headers.has('Authorization') ? '[PRESENT]' : '[MISSING]',
|
|
||||||
contentType: req.headers.get('Content-Type'),
|
|
||||||
traceparent: req.headers.get('traceparent') || '[NONE]'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle CORS preflight requests
|
setSpanAttributes(rootSpan, {
|
||||||
if (req.method === 'OPTIONS') {
|
'user.id': user.id,
|
||||||
return new Response(null, {
|
'function.name': 'process-selective-approval'
|
||||||
status: 204,
|
});
|
||||||
headers: corsHeaders
|
|
||||||
|
// 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract parent span context from headers (if present)
|
// STEP 1: Parse and validate request
|
||||||
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
addSpanEvent(rootSpan, 'validation_start');
|
||||||
|
|
||||||
// Create root span for this edge function invocation
|
let submissionId: string;
|
||||||
const rootSpan = startSpan(
|
let itemIds: string[];
|
||||||
'process-selective-approval',
|
|
||||||
'SERVER',
|
|
||||||
parentSpanContext,
|
|
||||||
{
|
|
||||||
'http.method': 'POST',
|
|
||||||
'function.name': 'process-selective-approval',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const requestId = rootSpan.spanId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STEP 1: Authentication
|
const body = await req.json();
|
||||||
addSpanEvent(rootSpan, 'authentication_start');
|
const validated = validateApprovalRequest(body, requestId);
|
||||||
const authHeader = req.headers.get('Authorization');
|
submissionId = validated.submissionId;
|
||||||
if (!authHeader) {
|
itemIds = validated.itemIds;
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { reason: 'missing_header' });
|
} catch (error) {
|
||||||
endSpan(rootSpan, 'error');
|
if (error instanceof ValidationError) {
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rootSpan, 'validation_failed', {
|
||||||
return new Response(
|
field: error.field,
|
||||||
JSON.stringify({ error: 'Missing Authorization header' }),
|
expected: error.expected,
|
||||||
{
|
received: error.received,
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
||||||
global: { headers: { Authorization: authHeader } }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
||||||
if (authError || !user) {
|
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { error: authError?.message });
|
|
||||||
edgeLogger.warn('Authentication failed', {
|
|
||||||
requestId,
|
|
||||||
error: authError?.message,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
});
|
||||||
endSpan(rootSpan, 'error', authError || new Error('Unauthorized'));
|
throw error; // Will be caught by wrapper
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Unauthorized' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('x-idempotency-key');
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
||||||
|
throw new ValidationError('idempotency_key', 'Missing X-Idempotency-Key header', 'string', 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, { 'user.id': user.id });
|
setSpanAttributes(rootSpan, {
|
||||||
addSpanEvent(rootSpan, 'authentication_success');
|
'submission.id': submissionId,
|
||||||
edgeLogger.info('Approval request received', {
|
'submission.item_count': itemIds.length,
|
||||||
requestId,
|
'idempotency.key': idempotencyKey,
|
||||||
moderatorId: user.id,
|
});
|
||||||
action: 'process_approval'
|
addSpanEvent(rootSpan, 'validation_complete');
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 2: Parse and validate request
|
// STEP 2: Idempotency check with timeout
|
||||||
addSpanEvent(rootSpan, 'validation_start');
|
addSpanEvent(rootSpan, 'idempotency_check_start');
|
||||||
|
|
||||||
let submissionId: string;
|
const idempotencyCheckPromise = supabase
|
||||||
let itemIds: string[];
|
.from('submission_idempotency_keys')
|
||||||
|
.select('*')
|
||||||
try {
|
.eq('idempotency_key', idempotencyKey)
|
||||||
const body = await req.json();
|
.single();
|
||||||
const validated = validateApprovalRequest(body, requestId);
|
|
||||||
submissionId = validated.submissionId;
|
// Add 5 second timeout for idempotency check
|
||||||
itemIds = validated.itemIds;
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
} catch (error) {
|
setTimeout(() => reject(new Error('Idempotency check timed out after 5s')), 5000)
|
||||||
if (error instanceof ValidationError) {
|
);
|
||||||
addSpanEvent(rootSpan, 'validation_failed', {
|
|
||||||
field: error.field,
|
let existingKey;
|
||||||
expected: error.expected,
|
try {
|
||||||
received: error.received,
|
const result = await Promise.race([
|
||||||
});
|
idempotencyCheckPromise,
|
||||||
edgeLogger.warn('Request validation failed', {
|
timeoutPromise
|
||||||
requestId,
|
]) as any;
|
||||||
field: error.field,
|
existingKey = result.data;
|
||||||
expected: error.expected,
|
} catch (timeoutError: any) {
|
||||||
received: error.received,
|
addSpanEvent(rootSpan, 'idempotency_check_timeout', { error: timeoutError.message });
|
||||||
action: 'process_approval'
|
throw new Error(`Database query timeout: ${timeoutError.message}`);
|
||||||
});
|
}
|
||||||
endSpan(rootSpan, 'error', error);
|
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rootSpan, 'idempotency_check_complete', {
|
||||||
return new Response(
|
foundKey: !!existingKey,
|
||||||
JSON.stringify({
|
status: existingKey?.status
|
||||||
error: error.message,
|
});
|
||||||
field: error.field,
|
|
||||||
requestId
|
if (existingKey?.status === 'completed') {
|
||||||
}),
|
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
||||||
{
|
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
||||||
status: 400,
|
return new Response(
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
JSON.stringify(existingKey.result_data),
|
||||||
}
|
{
|
||||||
);
|
status: 200,
|
||||||
|
headers: { 'X-Cache-Status': 'HIT' }
|
||||||
}
|
}
|
||||||
throw error;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('x-idempotency-key');
|
|
||||||
|
|
||||||
if (!idempotencyKey) {
|
// STEP 3: Fetch submission to get submitter_id
|
||||||
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
const { data: submission, error: submissionError } = await supabase
|
||||||
edgeLogger.warn('Missing idempotency key', { requestId });
|
.from('content_submissions')
|
||||||
endSpan(rootSpan, 'error');
|
.select('user_id, status, assigned_to')
|
||||||
logSpan(rootSpan);
|
.eq('id', submissionId)
|
||||||
return new Response(
|
.single();
|
||||||
JSON.stringify({ error: 'Missing X-Idempotency-Key header' }),
|
|
||||||
{
|
if (submissionError || !submission) {
|
||||||
status: 400,
|
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
throw new Error('Submission not found');
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// STEP 4: Verify moderator can approve this submission
|
||||||
|
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||||
|
throw new Error('Submission is locked by another moderator');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
||||||
|
throw new Error('Submission already processed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 5: Register idempotency key as processing (atomic upsert)
|
||||||
|
if (!existingKey) {
|
||||||
|
const { data: insertedKey, error: idempotencyError } = await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.insert({
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
submission_id: submissionId,
|
||||||
|
moderator_id: user.id,
|
||||||
|
item_ids: itemIds,
|
||||||
|
status: 'processing'
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (idempotencyError && idempotencyError.code === '23505') {
|
||||||
|
throw new Error('Another moderator is processing this submission');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, {
|
if (idempotencyError) {
|
||||||
|
throw toError(idempotencyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create child span for RPC transaction
|
||||||
|
const rpcSpan = startSpan(
|
||||||
|
'process_approval_transaction',
|
||||||
|
'DATABASE',
|
||||||
|
getSpanContext(rootSpan),
|
||||||
|
{
|
||||||
|
'db.operation': 'rpc',
|
||||||
|
'db.function': 'process_approval_transaction',
|
||||||
'submission.id': submissionId,
|
'submission.id': submissionId,
|
||||||
'submission.item_count': itemIds.length,
|
'submission.item_count': itemIds.length,
|
||||||
'idempotency.key': idempotencyKey,
|
|
||||||
});
|
|
||||||
addSpanEvent(rootSpan, 'validation_complete');
|
|
||||||
edgeLogger.info('Request validated', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 3: Idempotency check
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_check_start');
|
|
||||||
const { data: existingKey } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.select('*')
|
|
||||||
.eq('idempotency_key', idempotencyKey)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (existingKey?.status === 'completed') {
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
|
||||||
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
|
||||||
edgeLogger.info('Idempotency cache hit', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
cached: true,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(existingKey.result_data),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Cache-Status': 'HIT'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// STEP 4: Fetch submission to get submitter_id
|
addSpanEvent(rpcSpan, 'rpc_call_start');
|
||||||
const { data: submission, error: submissionError } = await supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('user_id, status, assigned_to')
|
|
||||||
.eq('id', submissionId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (submissionError || !submission) {
|
// STEP 6: Call RPC function with deadlock retry logic
|
||||||
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
let retryCount = 0;
|
||||||
edgeLogger.error('Submission not found', {
|
const MAX_DEADLOCK_RETRIES = 3;
|
||||||
requestId,
|
let result: any = null;
|
||||||
submissionId,
|
let rpcError: any = null;
|
||||||
error: submissionError?.message,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', submissionError || new Error('Submission not found'));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission not found' }),
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 5: Verify moderator can approve this submission
|
while (retryCount <= MAX_DEADLOCK_RETRIES) {
|
||||||
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
const { data, error } = await supabase.rpc(
|
||||||
edgeLogger.warn('Lock conflict', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
lockedBy: submission.assigned_to,
|
|
||||||
attemptedBy: user.id,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission is locked by another moderator' }),
|
|
||||||
{
|
|
||||||
status: 409,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
|
||||||
edgeLogger.warn('Invalid submission status', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
currentStatus: submission.status,
|
|
||||||
expectedStatuses: ['pending', 'partially_approved'],
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission already processed' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 6: Register idempotency key as processing (atomic upsert)
|
|
||||||
// ✅ CRITICAL FIX: Use ON CONFLICT to prevent race conditions
|
|
||||||
if (!existingKey) {
|
|
||||||
const { data: insertedKey, error: idempotencyError } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.insert({
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
submission_id: submissionId,
|
|
||||||
moderator_id: user.id,
|
|
||||||
item_ids: itemIds,
|
|
||||||
status: 'processing'
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// If conflict occurred, another moderator is processing
|
|
||||||
if (idempotencyError && idempotencyError.code === '23505') {
|
|
||||||
edgeLogger.warn('Idempotency key conflict - another request processing', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
moderatorId: user.id
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Another moderator is processing this submission' }),
|
|
||||||
{ status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idempotencyError) {
|
|
||||||
throw toError(idempotencyError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create child span for RPC transaction
|
|
||||||
const rpcSpan = startSpan(
|
|
||||||
'process_approval_transaction',
|
'process_approval_transaction',
|
||||||
'DATABASE',
|
|
||||||
getSpanContext(rootSpan),
|
|
||||||
{
|
{
|
||||||
'db.operation': 'rpc',
|
p_submission_id: submissionId,
|
||||||
'db.function': 'process_approval_transaction',
|
p_item_ids: itemIds,
|
||||||
'submission.id': submissionId,
|
p_moderator_id: user.id,
|
||||||
'submission.item_count': itemIds.length,
|
p_submitter_id: submission.user_id,
|
||||||
|
p_request_id: requestId,
|
||||||
|
p_trace_id: rootSpan.traceId,
|
||||||
|
p_parent_span_id: rpcSpan.spanId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_start');
|
result = data;
|
||||||
edgeLogger.info('Calling approval transaction RPC', {
|
rpcError = error;
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
if (!rpcError) {
|
||||||
// STEP 7: Call RPC function with deadlock retry logic
|
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
||||||
// ============================================================================
|
'result.status': data?.status,
|
||||||
let retryCount = 0;
|
'items.processed': itemIds.length,
|
||||||
const MAX_DEADLOCK_RETRIES = 3;
|
|
||||||
let result: any = null;
|
|
||||||
let rpcError: any = null;
|
|
||||||
|
|
||||||
while (retryCount <= MAX_DEADLOCK_RETRIES) {
|
|
||||||
const { data, error } = await supabase.rpc(
|
|
||||||
'process_approval_transaction',
|
|
||||||
{
|
|
||||||
p_submission_id: submissionId,
|
|
||||||
p_item_ids: itemIds,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
result = data;
|
|
||||||
rpcError = error;
|
|
||||||
|
|
||||||
if (!rpcError) {
|
|
||||||
// Success!
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
|
||||||
'result.status': data?.status,
|
|
||||||
'items.processed': itemIds.length,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for deadlock (40P01) or serialization failure (40001)
|
|
||||||
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
|
||||||
retryCount++;
|
|
||||||
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
|
||||||
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
|
||||||
edgeLogger.error('Max deadlock retries exceeded', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
attempt: retryCount,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backoffMs = 100 * Math.pow(2, retryCount);
|
|
||||||
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
|
||||||
edgeLogger.warn('Deadlock detected, retrying', {
|
|
||||||
requestId,
|
|
||||||
attempt: retryCount,
|
|
||||||
maxAttempts: MAX_DEADLOCK_RETRIES,
|
|
||||||
backoffMs,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
await new Promise(r => setTimeout(r, backoffMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-retryable error, break immediately
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enhanced error logging for type mismatches
|
|
||||||
if (rpcError.code === 'P0001' && rpcError.message?.includes('Unknown item type')) {
|
|
||||||
// Extract the unknown type from error message
|
|
||||||
const typeMatch = rpcError.message.match(/Unknown item type: (\w+)/);
|
|
||||||
const unknownType = typeMatch ? typeMatch[1] : 'unknown';
|
|
||||||
|
|
||||||
edgeLogger.error('Entity type mismatch detected', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
unknownType,
|
|
||||||
error: rpcError.message,
|
|
||||||
hint: `The submission contains an item with type '${unknownType}' which is not recognized by process_approval_transaction. ` +
|
|
||||||
`Valid types are: park, ride, manufacturer, operator, property_owner, designer, company, ride_model, photo. ` +
|
|
||||||
`This indicates a data model inconsistency between submission_items and the RPC function.`,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rpcError) {
|
// Check for deadlock (40P01) or serialization failure (40001)
|
||||||
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
||||||
endSpan(rpcSpan, 'error', rpcError);
|
retryCount++;
|
||||||
logSpan(rpcSpan);
|
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
||||||
|
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
||||||
edgeLogger.error('Transaction failed', {
|
break;
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code,
|
|
||||||
retries: retryCount,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update idempotency key to failed
|
|
||||||
try {
|
|
||||||
await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.update({
|
|
||||||
status: 'failed',
|
|
||||||
error_message: rpcError.message,
|
|
||||||
completed_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('idempotency_key', idempotencyKey);
|
|
||||||
} catch (updateError) {
|
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'failed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
// Non-blocking - continue with error response even if idempotency update fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endSpan(rootSpan, 'error', rpcError);
|
const backoffMs = 100 * Math.pow(2, retryCount);
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
||||||
|
await new Promise(r => setTimeout(r, backoffMs));
|
||||||
return new Response(
|
continue;
|
||||||
JSON.stringify({
|
|
||||||
error: 'Approval transaction failed',
|
|
||||||
message: rpcError.message,
|
|
||||||
details: rpcError.details,
|
|
||||||
retries: retryCount
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPC succeeded
|
// Non-retryable error
|
||||||
endSpan(rpcSpan, 'ok');
|
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
||||||
logSpan(rpcSpan);
|
error: rpcError.message,
|
||||||
|
errorCode: rpcError.code
|
||||||
setSpanAttributes(rootSpan, {
|
|
||||||
'result.status': result?.status,
|
|
||||||
'result.final_status': result?.status,
|
|
||||||
'retries': retryCount,
|
|
||||||
});
|
|
||||||
edgeLogger.info('Transaction completed successfully', {
|
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
retries: retryCount,
|
|
||||||
newStatus: result?.status,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// STEP 8: Success - update idempotency key
|
if (rpcError) {
|
||||||
|
endSpan(rpcSpan, 'error', rpcError);
|
||||||
|
|
||||||
|
// Update idempotency key to failed
|
||||||
try {
|
try {
|
||||||
await supabase
|
await supabase
|
||||||
.from('submission_idempotency_keys')
|
.from('submission_idempotency_keys')
|
||||||
.update({
|
.update({
|
||||||
status: 'completed',
|
status: 'failed',
|
||||||
result_data: result,
|
error_message: rpcError.message,
|
||||||
completed_at: new Date().toISOString()
|
completed_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('idempotency_key', idempotencyKey);
|
.eq('idempotency_key', idempotencyKey);
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
// Non-blocking
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'completed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
// Non-blocking - transaction succeeded, so continue with success response
|
|
||||||
}
|
}
|
||||||
|
throw toError(rpcError);
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(result),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-Id': requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Enhanced error logging with full details
|
|
||||||
const errorDetails = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
requestId: rootSpan?.spanId || 'unknown',
|
|
||||||
duration: rootSpan?.duration || 0,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
action: 'process_approval'
|
|
||||||
};
|
|
||||||
|
|
||||||
console.error('Uncaught error in handler', errorDetails);
|
|
||||||
|
|
||||||
endSpan(rootSpan, 'error', error instanceof Error ? error : toError(error));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
edgeLogger.error('Unexpected error', errorDetails);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
requestId: rootSpan?.spanId || 'unknown'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPC succeeded
|
||||||
|
endSpan(rpcSpan, 'ok');
|
||||||
|
|
||||||
|
setSpanAttributes(rootSpan, {
|
||||||
|
'result.status': result?.status,
|
||||||
|
'result.final_status': result?.status,
|
||||||
|
'retries': retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// STEP 7: Success - update idempotency key
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.update({
|
||||||
|
status: 'completed',
|
||||||
|
result_data: result,
|
||||||
|
completed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('idempotency_key', idempotencyKey);
|
||||||
|
} catch (updateError) {
|
||||||
|
// Non-blocking - transaction succeeded, so continue with success response
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply rate limiting: 10 requests per minute per IP (moderate tier for moderation actions)
|
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||||
serve(withRateLimit(handler, rateLimiters.moderate, corsHeaders));
|
serve(createEdgeFunction(
|
||||||
|
{
|
||||||
|
name: 'process-selective-approval',
|
||||||
|
requireAuth: true,
|
||||||
|
corsHeaders: corsHeadersWithTracing,
|
||||||
|
},
|
||||||
|
handler
|
||||||
|
));
|
||||||
|
|||||||
@@ -1,548 +1,249 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
|
||||||
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
|
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
|
||||||
import {
|
import {
|
||||||
edgeLogger,
|
|
||||||
startSpan,
|
|
||||||
endSpan,
|
|
||||||
addSpanEvent,
|
addSpanEvent,
|
||||||
setSpanAttributes,
|
setSpanAttributes,
|
||||||
getSpanContext,
|
getSpanContext,
|
||||||
logSpan,
|
startSpan,
|
||||||
extractSpanContextFromHeaders,
|
endSpan,
|
||||||
type Span
|
|
||||||
} from '../_shared/logger.ts';
|
} from '../_shared/logger.ts';
|
||||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
import { toError } from '../_shared/errorFormatter.ts';
|
||||||
import {
|
import {
|
||||||
validateRejectionRequest,
|
validateRejectionRequest,
|
||||||
type ValidatedRejectionRequest
|
|
||||||
} from '../_shared/submissionValidation.ts';
|
} from '../_shared/submissionValidation.ts';
|
||||||
import { ValidationError } from '../_shared/typeValidation.ts';
|
import { ValidationError } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!;
|
|
||||||
|
|
||||||
interface RejectionRequest {
|
|
||||||
submissionId: string;
|
|
||||||
itemIds: string[];
|
|
||||||
rejectionReason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main handler function
|
// Main handler function
|
||||||
const handler = async (req: Request) => {
|
const handler = async (req: Request, context: { supabase: any; user: any; span: any; requestId: string }) => {
|
||||||
// Handle CORS preflight requests
|
const { supabase, user, span: rootSpan, requestId } = context;
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 204,
|
|
||||||
headers: corsHeaders
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract parent span context from headers (if present)
|
setSpanAttributes(rootSpan, {
|
||||||
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
'user.id': user.id,
|
||||||
|
'function.name': 'process-selective-rejection'
|
||||||
|
});
|
||||||
|
|
||||||
// Create root span for this edge function invocation
|
// STEP 1: Parse and validate request
|
||||||
const rootSpan = startSpan(
|
addSpanEvent(rootSpan, 'validation_start');
|
||||||
'process-selective-rejection',
|
|
||||||
'SERVER',
|
let submissionId: string;
|
||||||
parentSpanContext,
|
let itemIds: string[];
|
||||||
{
|
let rejectionReason: string;
|
||||||
'http.method': 'POST',
|
|
||||||
'function.name': 'process-selective-rejection',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const requestId = rootSpan.spanId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STEP 1: Authentication
|
const body = await req.json();
|
||||||
addSpanEvent(rootSpan, 'authentication_start');
|
const validated = validateRejectionRequest(body, requestId);
|
||||||
const authHeader = req.headers.get('Authorization');
|
submissionId = validated.submissionId;
|
||||||
if (!authHeader) {
|
itemIds = validated.itemIds;
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { reason: 'missing_header' });
|
rejectionReason = validated.rejectionReason;
|
||||||
endSpan(rootSpan, 'error');
|
} catch (error) {
|
||||||
logSpan(rootSpan);
|
if (error instanceof ValidationError) {
|
||||||
return new Response(
|
addSpanEvent(rootSpan, 'validation_failed', {
|
||||||
JSON.stringify({ error: 'Missing Authorization header' }),
|
field: error.field,
|
||||||
{
|
expected: error.expected,
|
||||||
status: 401,
|
received: error.received,
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
||||||
global: { headers: { Authorization: authHeader } }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
||||||
if (authError || !user) {
|
|
||||||
addSpanEvent(rootSpan, 'authentication_failed', { error: authError?.message });
|
|
||||||
edgeLogger.warn('Authentication failed', {
|
|
||||||
requestId,
|
|
||||||
error: authError?.message,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
});
|
||||||
endSpan(rootSpan, 'error', authError || new Error('Unauthorized'));
|
throw error; // Will be caught by wrapper
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Unauthorized' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('x-idempotency-key');
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
||||||
|
throw new ValidationError('idempotency_key', 'Missing X-Idempotency-Key header', 'string', 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, { 'user.id': user.id });
|
setSpanAttributes(rootSpan, {
|
||||||
addSpanEvent(rootSpan, 'authentication_success');
|
'submission.id': submissionId,
|
||||||
edgeLogger.info('Rejection request received', {
|
'submission.item_count': itemIds.length,
|
||||||
requestId,
|
'idempotency.key': idempotencyKey,
|
||||||
moderatorId: user.id,
|
});
|
||||||
action: 'process_rejection'
|
addSpanEvent(rootSpan, 'validation_complete');
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 2: Parse and validate request
|
// STEP 2: Idempotency check
|
||||||
addSpanEvent(rootSpan, 'validation_start');
|
addSpanEvent(rootSpan, 'idempotency_check_start');
|
||||||
|
const { data: existingKey } = await supabase
|
||||||
let submissionId: string;
|
.from('submission_idempotency_keys')
|
||||||
let itemIds: string[];
|
.select('*')
|
||||||
let rejectionReason: string;
|
.eq('idempotency_key', idempotencyKey)
|
||||||
|
.single();
|
||||||
try {
|
|
||||||
const body = await req.json();
|
if (existingKey?.status === 'completed') {
|
||||||
const validated = validateRejectionRequest(body, requestId);
|
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
||||||
submissionId = validated.submissionId;
|
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
||||||
itemIds = validated.itemIds;
|
return new Response(
|
||||||
rejectionReason = validated.rejectionReason;
|
JSON.stringify(existingKey.result_data),
|
||||||
} catch (error) {
|
{
|
||||||
if (error instanceof ValidationError) {
|
status: 200,
|
||||||
addSpanEvent(rootSpan, 'validation_failed', {
|
headers: { 'X-Cache-Status': 'HIT' }
|
||||||
field: error.field,
|
|
||||||
expected: error.expected,
|
|
||||||
received: error.received,
|
|
||||||
});
|
|
||||||
edgeLogger.warn('Request validation failed', {
|
|
||||||
requestId,
|
|
||||||
field: error.field,
|
|
||||||
expected: error.expected,
|
|
||||||
received: error.received,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', error);
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: error.message,
|
|
||||||
field: error.field,
|
|
||||||
requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw error;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('x-idempotency-key');
|
|
||||||
|
|
||||||
if (!idempotencyKey) {
|
// STEP 3: Fetch submission to verify
|
||||||
addSpanEvent(rootSpan, 'validation_failed', { reason: 'missing_idempotency_key' });
|
const { data: submission, error: submissionError } = await supabase
|
||||||
edgeLogger.warn('Missing idempotency key', { requestId });
|
.from('content_submissions')
|
||||||
endSpan(rootSpan, 'error');
|
.select('user_id, status, assigned_to')
|
||||||
logSpan(rootSpan);
|
.eq('id', submissionId)
|
||||||
return new Response(
|
.single();
|
||||||
JSON.stringify({ error: 'Missing X-Idempotency-Key header' }),
|
|
||||||
{
|
if (submissionError || !submission) {
|
||||||
status: 400,
|
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
throw new Error('Submission not found');
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// STEP 4: Verify moderator can reject this submission
|
||||||
|
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||||
|
throw new Error('Submission is locked by another moderator');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
||||||
|
throw new Error('Submission already processed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 5: Register idempotency key as processing (atomic upsert)
|
||||||
|
if (!existingKey) {
|
||||||
|
const { data: insertedKey, error: idempotencyError } = await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.insert({
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
submission_id: submissionId,
|
||||||
|
moderator_id: user.id,
|
||||||
|
item_ids: itemIds,
|
||||||
|
status: 'processing'
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (idempotencyError && idempotencyError.code === '23505') {
|
||||||
|
throw new Error('Another moderator is processing this submission');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, {
|
if (idempotencyError) {
|
||||||
|
throw toError(idempotencyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create child span for RPC transaction
|
||||||
|
const rpcSpan = startSpan(
|
||||||
|
'process_rejection_transaction',
|
||||||
|
'DATABASE',
|
||||||
|
getSpanContext(rootSpan),
|
||||||
|
{
|
||||||
|
'db.operation': 'rpc',
|
||||||
|
'db.function': 'process_rejection_transaction',
|
||||||
'submission.id': submissionId,
|
'submission.id': submissionId,
|
||||||
'submission.item_count': itemIds.length,
|
'submission.item_count': itemIds.length,
|
||||||
'idempotency.key': idempotencyKey,
|
|
||||||
});
|
|
||||||
addSpanEvent(rootSpan, 'validation_complete');
|
|
||||||
edgeLogger.info('Request validated', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 3: Idempotency check
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_check_start');
|
|
||||||
const { data: existingKey } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.select('*')
|
|
||||||
.eq('idempotency_key', idempotencyKey)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (existingKey?.status === 'completed') {
|
|
||||||
addSpanEvent(rootSpan, 'idempotency_cache_hit');
|
|
||||||
setSpanAttributes(rootSpan, { 'cache.hit': true });
|
|
||||||
edgeLogger.info('Idempotency cache hit', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
cached: true,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(existingKey.result_data),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Cache-Status': 'HIT'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// STEP 4: Fetch submission to get submitter_id
|
addSpanEvent(rpcSpan, 'rpc_call_start');
|
||||||
const { data: submission, error: submissionError } = await supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.select('user_id, status, assigned_to')
|
|
||||||
.eq('id', submissionId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (submissionError || !submission) {
|
// STEP 6: Call RPC function with deadlock retry logic
|
||||||
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
let retryCount = 0;
|
||||||
edgeLogger.error('Submission not found', {
|
const MAX_DEADLOCK_RETRIES = 3;
|
||||||
requestId,
|
let result: any = null;
|
||||||
submissionId,
|
let rpcError: any = null;
|
||||||
error: submissionError?.message,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error', submissionError || new Error('Submission not found'));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission not found' }),
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 5: Verify moderator can reject this submission
|
while (retryCount <= MAX_DEADLOCK_RETRIES) {
|
||||||
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
const { data, error } = await supabase.rpc(
|
||||||
edgeLogger.warn('Lock conflict', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
lockedBy: submission.assigned_to,
|
|
||||||
attemptedBy: user.id,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission is locked by another moderator' }),
|
|
||||||
{
|
|
||||||
status: 409,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
|
||||||
edgeLogger.warn('Invalid submission status', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
currentStatus: submission.status,
|
|
||||||
expectedStatuses: ['pending', 'partially_approved'],
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Submission already processed' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 6: Register idempotency key as processing (atomic upsert)
|
|
||||||
// ✅ CRITICAL FIX: Use ON CONFLICT to prevent race conditions
|
|
||||||
if (!existingKey) {
|
|
||||||
const { data: insertedKey, error: idempotencyError } = await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.insert({
|
|
||||||
idempotency_key: idempotencyKey,
|
|
||||||
submission_id: submissionId,
|
|
||||||
moderator_id: user.id,
|
|
||||||
item_ids: itemIds,
|
|
||||||
status: 'processing'
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// If conflict occurred, another moderator is processing
|
|
||||||
if (idempotencyError && idempotencyError.code === '23505') {
|
|
||||||
edgeLogger.warn('Idempotency key conflict - another request processing', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
moderatorId: user.id
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Another moderator is processing this submission' }),
|
|
||||||
{ status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idempotencyError) {
|
|
||||||
throw toError(idempotencyError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create child span for RPC transaction
|
|
||||||
const rpcSpan = startSpan(
|
|
||||||
'process_rejection_transaction',
|
'process_rejection_transaction',
|
||||||
'DATABASE',
|
|
||||||
getSpanContext(rootSpan),
|
|
||||||
{
|
{
|
||||||
'db.operation': 'rpc',
|
p_submission_id: submissionId,
|
||||||
'db.function': 'process_rejection_transaction',
|
p_item_ids: itemIds,
|
||||||
'submission.id': submissionId,
|
p_moderator_id: user.id,
|
||||||
'submission.item_count': itemIds.length,
|
p_rejection_reason: rejectionReason,
|
||||||
|
p_request_id: requestId,
|
||||||
|
p_trace_id: rootSpan.traceId,
|
||||||
|
p_parent_span_id: rpcSpan.spanId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_start');
|
result = data;
|
||||||
edgeLogger.info('Calling rejection transaction RPC', {
|
rpcError = error;
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
if (!rpcError) {
|
||||||
// STEP 7: Call RPC function with deadlock retry logic
|
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
||||||
// ============================================================================
|
'result.status': data?.status,
|
||||||
let retryCount = 0;
|
'items.processed': itemIds.length,
|
||||||
const MAX_DEADLOCK_RETRIES = 3;
|
|
||||||
let result: any = null;
|
|
||||||
let rpcError: any = null;
|
|
||||||
|
|
||||||
while (retryCount <= MAX_DEADLOCK_RETRIES) {
|
|
||||||
const { data, error } = await supabase.rpc(
|
|
||||||
'process_rejection_transaction',
|
|
||||||
{
|
|
||||||
p_submission_id: submissionId,
|
|
||||||
p_item_ids: itemIds,
|
|
||||||
p_moderator_id: user.id,
|
|
||||||
p_rejection_reason: rejectionReason,
|
|
||||||
p_request_id: requestId,
|
|
||||||
p_trace_id: rootSpan.traceId,
|
|
||||||
p_parent_span_id: rpcSpan.spanId
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
result = data;
|
|
||||||
rpcError = error;
|
|
||||||
|
|
||||||
if (!rpcError) {
|
|
||||||
// Success!
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
|
||||||
'result.status': data?.status,
|
|
||||||
'items.processed': itemIds.length,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for deadlock (40P01) or serialization failure (40001)
|
|
||||||
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
|
||||||
retryCount++;
|
|
||||||
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
|
||||||
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
|
||||||
edgeLogger.error('Max deadlock retries exceeded', {
|
|
||||||
requestId,
|
|
||||||
submissionId,
|
|
||||||
attempt: retryCount,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backoffMs = 100 * Math.pow(2, retryCount);
|
|
||||||
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
|
||||||
edgeLogger.warn('Deadlock detected, retrying', {
|
|
||||||
requestId,
|
|
||||||
attempt: retryCount,
|
|
||||||
maxAttempts: MAX_DEADLOCK_RETRIES,
|
|
||||||
backoffMs,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
await new Promise(r => setTimeout(r, backoffMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-retryable error, break immediately
|
|
||||||
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rpcError) {
|
// Check for deadlock (40P01) or serialization failure (40001)
|
||||||
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
||||||
endSpan(rpcSpan, 'error', rpcError);
|
retryCount++;
|
||||||
logSpan(rpcSpan);
|
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
||||||
|
addSpanEvent(rpcSpan, 'max_retries_exceeded', { attempt: retryCount });
|
||||||
edgeLogger.error('Transaction failed', {
|
break;
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
error: rpcError.message,
|
|
||||||
errorCode: rpcError.code,
|
|
||||||
retries: retryCount,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update idempotency key to failed
|
|
||||||
try {
|
|
||||||
await supabase
|
|
||||||
.from('submission_idempotency_keys')
|
|
||||||
.update({
|
|
||||||
status: 'failed',
|
|
||||||
error_message: rpcError.message,
|
|
||||||
completed_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('idempotency_key', idempotencyKey);
|
|
||||||
} catch (updateError) {
|
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'failed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
// Non-blocking - continue with error response even if idempotency update fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endSpan(rootSpan, 'error', rpcError);
|
const backoffMs = 100 * Math.pow(2, retryCount);
|
||||||
logSpan(rootSpan);
|
addSpanEvent(rpcSpan, 'deadlock_retry', { attempt: retryCount, backoffMs });
|
||||||
|
await new Promise(r => setTimeout(r, backoffMs));
|
||||||
return new Response(
|
continue;
|
||||||
JSON.stringify({
|
|
||||||
error: 'Rejection transaction failed',
|
|
||||||
message: rpcError.message,
|
|
||||||
details: rpcError.details,
|
|
||||||
retries: retryCount
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RPC succeeded
|
// Non-retryable error
|
||||||
endSpan(rpcSpan, 'ok');
|
addSpanEvent(rpcSpan, 'rpc_call_failed', {
|
||||||
logSpan(rpcSpan);
|
error: rpcError.message,
|
||||||
|
errorCode: rpcError.code
|
||||||
setSpanAttributes(rootSpan, {
|
|
||||||
'result.status': result?.status,
|
|
||||||
'result.final_status': result?.status,
|
|
||||||
'retries': retryCount,
|
|
||||||
});
|
|
||||||
edgeLogger.info('Transaction completed successfully', {
|
|
||||||
requestId,
|
|
||||||
duration: rpcSpan.duration,
|
|
||||||
submissionId,
|
|
||||||
itemCount: itemIds.length,
|
|
||||||
retries: retryCount,
|
|
||||||
newStatus: result?.status,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// STEP 8: Success - update idempotency key
|
if (rpcError) {
|
||||||
|
endSpan(rpcSpan, 'error', rpcError);
|
||||||
|
|
||||||
|
// Update idempotency key to failed
|
||||||
try {
|
try {
|
||||||
await supabase
|
await supabase
|
||||||
.from('submission_idempotency_keys')
|
.from('submission_idempotency_keys')
|
||||||
.update({
|
.update({
|
||||||
status: 'completed',
|
status: 'failed',
|
||||||
result_data: result,
|
error_message: rpcError.message,
|
||||||
completed_at: new Date().toISOString()
|
completed_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('idempotency_key', idempotencyKey);
|
.eq('idempotency_key', idempotencyKey);
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
edgeLogger.warn('Failed to update idempotency key', {
|
// Non-blocking
|
||||||
requestId,
|
|
||||||
idempotencyKey,
|
|
||||||
status: 'completed',
|
|
||||||
error: formatEdgeError(updateError),
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
// Non-blocking - transaction succeeded, so continue with success response
|
|
||||||
}
|
}
|
||||||
|
throw toError(rpcError);
|
||||||
endSpan(rootSpan, 'ok');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(result),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-Id': requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
endSpan(rootSpan, 'error', error instanceof Error ? error : toError(error));
|
|
||||||
logSpan(rootSpan);
|
|
||||||
|
|
||||||
edgeLogger.error('Unexpected error', {
|
|
||||||
requestId,
|
|
||||||
duration: rootSpan.duration,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPC succeeded
|
||||||
|
endSpan(rpcSpan, 'ok');
|
||||||
|
|
||||||
|
setSpanAttributes(rootSpan, {
|
||||||
|
'result.status': result?.status,
|
||||||
|
'result.final_status': result?.status,
|
||||||
|
'retries': retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// STEP 7: Success - update idempotency key
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.update({
|
||||||
|
status: 'completed',
|
||||||
|
result_data: result,
|
||||||
|
completed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('idempotency_key', idempotencyKey);
|
||||||
|
} catch (updateError) {
|
||||||
|
// Non-blocking - transaction succeeded, so continue with success response
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply rate limiting: 10 requests per minute per IP (moderate tier for moderation actions)
|
// Create edge function with automatic error handling, CORS, auth, and logging
|
||||||
serve(withRateLimit(handler, rateLimiters.moderate, corsHeaders));
|
createEdgeFunction(
|
||||||
|
{
|
||||||
|
name: 'process-selective-rejection',
|
||||||
|
requireAuth: true,
|
||||||
|
corsEnabled: true,
|
||||||
|
enableTracing: true,
|
||||||
|
rateLimitTier: 'moderate'
|
||||||
|
},
|
||||||
|
handler
|
||||||
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user