mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 14:11:13 -05:00
Compare commits
32 Commits
django-bac
...
73e847015d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e847015d | ||
|
|
8ed5edbe24 | ||
|
|
496ff48e34 | ||
|
|
b47d5392d5 | ||
|
|
c5d40d07df | ||
|
|
2d65f13b85 | ||
|
|
4a18462c37 | ||
|
|
f7f22f4817 | ||
|
|
ade1810a01 | ||
|
|
e0001961bf | ||
|
|
20cd434e73 | ||
|
|
3cb0f66064 | ||
|
|
ad31be1622 | ||
|
|
68d6690697 | ||
|
|
5169f42e2d | ||
|
|
095cd412be | ||
|
|
2731635b4d | ||
|
|
9a1ecb0663 | ||
|
|
00de87924c | ||
|
|
236e412d7c | ||
|
|
fce582e6ba | ||
|
|
89338a06ea | ||
|
|
96adb2b15e | ||
|
|
1551a2f08d | ||
|
|
94312c8ef0 | ||
|
|
c7bdff313a | ||
|
|
d5974440a5 | ||
|
|
6c03a5b0e7 | ||
|
|
92b5d6e33d | ||
|
|
a0f6c371fc | ||
|
|
403bc78765 | ||
|
|
26e38b6d49 |
186
.github/workflows/schema-validation.yml
vendored
Normal file
186
.github/workflows/schema-validation.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
name: Schema Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'supabase/migrations/**'
|
||||
- 'src/lib/moderation/**'
|
||||
- 'supabase/functions/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
paths:
|
||||
- 'supabase/migrations/**'
|
||||
- 'src/lib/moderation/**'
|
||||
- 'supabase/functions/**'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
validate-schema:
|
||||
name: Validate Database Schema
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run schema validation script
|
||||
env:
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||
run: |
|
||||
echo "🔍 Running schema validation checks..."
|
||||
npm run validate-schema
|
||||
|
||||
- name: Run Playwright schema validation tests
|
||||
env:
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||
run: |
|
||||
echo "🧪 Running integration tests..."
|
||||
npx playwright test schema-validation --reporter=list
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: schema-validation-results
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment PR with validation results
|
||||
if: failure() && github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `## ❌ Schema Validation Failed
|
||||
|
||||
The schema validation checks have detected inconsistencies in your database changes.
|
||||
|
||||
**Common issues:**
|
||||
- Missing fields in submission tables
|
||||
- Mismatched data types between tables
|
||||
- Missing version metadata fields
|
||||
- Invalid column names (e.g., \`ride_type\` in \`rides\` table)
|
||||
|
||||
**Next steps:**
|
||||
1. Review the failed tests in the Actions log
|
||||
2. Check the [Schema Reference documentation](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/docs/submission-pipeline/SCHEMA_REFERENCE.md)
|
||||
3. Fix the identified issues
|
||||
4. Push your fixes to re-run validation
|
||||
|
||||
**Need help?** Consult the [Integration Tests README](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/tests/integration/README.md).`
|
||||
})
|
||||
|
||||
migration-safety-check:
|
||||
name: Migration Safety Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for breaking changes in migrations
|
||||
run: |
|
||||
echo "🔍 Checking for potentially breaking migration patterns..."
|
||||
|
||||
# Check if any migrations contain DROP COLUMN
|
||||
if git diff origin/main...HEAD -- 'supabase/migrations/**' | grep -i "DROP COLUMN"; then
|
||||
echo "⚠️ Warning: Migration contains DROP COLUMN"
|
||||
echo "::warning::Migration contains DROP COLUMN - ensure data migration plan exists"
|
||||
fi
|
||||
|
||||
# Check if any migrations alter NOT NULL constraints
|
||||
if git diff origin/main...HEAD -- 'supabase/migrations/**' | grep -i "ALTER COLUMN.*NOT NULL"; then
|
||||
echo "⚠️ Warning: Migration alters NOT NULL constraints"
|
||||
echo "::warning::Migration alters NOT NULL constraints - ensure data backfill is complete"
|
||||
fi
|
||||
|
||||
# Check if any migrations rename columns
|
||||
if git diff origin/main...HEAD -- 'supabase/migrations/**' | grep -i "RENAME COLUMN"; then
|
||||
echo "⚠️ Warning: Migration renames columns"
|
||||
echo "::warning::Migration renames columns - ensure all code references are updated"
|
||||
fi
|
||||
|
||||
- name: Validate migration file naming
|
||||
run: |
|
||||
echo "🔍 Validating migration file names..."
|
||||
|
||||
# Check that all migration files follow the timestamp pattern
|
||||
for file in supabase/migrations/*.sql; do
|
||||
if [[ ! $(basename "$file") =~ ^[0-9]{14}_ ]]; then
|
||||
echo "❌ Invalid migration filename: $(basename "$file")"
|
||||
echo "::error::Migration files must start with a 14-digit timestamp (YYYYMMDDHHMMSS)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ All migration filenames are valid"
|
||||
|
||||
documentation-check:
|
||||
name: Documentation Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if schema docs need updating
|
||||
run: |
|
||||
echo "📚 Checking if schema documentation is up to date..."
|
||||
|
||||
# Check if migrations changed but SCHEMA_REFERENCE.md didn't
|
||||
MIGRATIONS_CHANGED=$(git diff origin/main...HEAD --name-only | grep -c "supabase/migrations/" || true)
|
||||
SCHEMA_DOCS_CHANGED=$(git diff origin/main...HEAD --name-only | grep -c "docs/submission-pipeline/SCHEMA_REFERENCE.md" || true)
|
||||
|
||||
if [ "$MIGRATIONS_CHANGED" -gt 0 ] && [ "$SCHEMA_DOCS_CHANGED" -eq 0 ]; then
|
||||
echo "⚠️ Warning: Migrations were changed but SCHEMA_REFERENCE.md was not updated"
|
||||
echo "::warning::Consider updating docs/submission-pipeline/SCHEMA_REFERENCE.md to reflect schema changes"
|
||||
else
|
||||
echo "✅ Documentation check passed"
|
||||
fi
|
||||
|
||||
- name: Comment PR with documentation reminder
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const migrationsChanged = (await exec.getExecOutput('git', ['diff', 'origin/main...HEAD', '--name-only'])).stdout.includes('supabase/migrations/');
|
||||
const docsChanged = (await exec.getExecOutput('git', ['diff', 'origin/main...HEAD', '--name-only'])).stdout.includes('docs/submission-pipeline/SCHEMA_REFERENCE.md');
|
||||
|
||||
if (migrationsChanged && !docsChanged) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `## 📚 Documentation Reminder
|
||||
|
||||
This PR includes database migrations but doesn't update the schema reference documentation.
|
||||
|
||||
**If you added/modified fields**, please update:
|
||||
- \`docs/submission-pipeline/SCHEMA_REFERENCE.md\`
|
||||
|
||||
**If this is a minor change** (e.g., fixing typos, adding indexes), you can ignore this message.`
|
||||
})
|
||||
}
|
||||
636
docs/submission-pipeline/SCHEMA_REFERENCE.md
Normal file
636
docs/submission-pipeline/SCHEMA_REFERENCE.md
Normal file
@@ -0,0 +1,636 @@
|
||||
# Submission Pipeline Schema Reference
|
||||
|
||||
**Critical Document**: This reference maps all entity types to their exact database schema fields across the entire submission pipeline to prevent schema mismatches.
|
||||
|
||||
**Last Updated**: 2025-11-08
|
||||
**Status**: ✅ All schemas audited and verified
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Parks](#parks)
|
||||
3. [Rides](#rides)
|
||||
4. [Companies](#companies)
|
||||
5. [Ride Models](#ride-models)
|
||||
6. [Photos](#photos)
|
||||
7. [Timeline Events](#timeline-events)
|
||||
8. [Critical Functions Reference](#critical-functions-reference)
|
||||
9. [Common Pitfalls](#common-pitfalls)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### Pipeline Flow
|
||||
|
||||
```
|
||||
User Input → *_submissions table → submission_items → Moderation →
|
||||
process_approval_transaction → create/update_entity_from_submission →
|
||||
Main entity table → Version trigger → *_versions table
|
||||
```
|
||||
|
||||
### Entity Types
|
||||
|
||||
- `park` - Theme parks and amusement parks
|
||||
- `ride` - Individual rides and attractions
|
||||
- `company` - Used for: `manufacturer`, `operator`, `designer`, `property_owner`
|
||||
- `ride_model` - Ride model templates
|
||||
- `photo` - Entity photos
|
||||
- `timeline_event` - Historical events
|
||||
|
||||
---
|
||||
|
||||
## Parks
|
||||
|
||||
### Main Table: `parks`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `name` (text, NOT NULL)
|
||||
- `slug` (text, NOT NULL, UNIQUE)
|
||||
- `park_type` (text, NOT NULL) - Values: `theme_park`, `amusement_park`, `water_park`, etc.
|
||||
- `status` (text, NOT NULL) - Values: `operating`, `closed`, `under_construction`, etc.
|
||||
|
||||
**Optional Fields:**
|
||||
- `description` (text)
|
||||
- `location_id` (uuid, FK → locations)
|
||||
- `operator_id` (uuid, FK → companies)
|
||||
- `property_owner_id` (uuid, FK → companies)
|
||||
- `opening_date` (date)
|
||||
- `closing_date` (date)
|
||||
- `opening_date_precision` (text) - Values: `year`, `month`, `day`
|
||||
- `closing_date_precision` (text)
|
||||
- `website_url` (text)
|
||||
- `phone` (text)
|
||||
- `email` (text)
|
||||
- `banner_image_url` (text)
|
||||
- `banner_image_id` (text)
|
||||
- `card_image_url` (text)
|
||||
- `card_image_id` (text)
|
||||
|
||||
**Metadata Fields:**
|
||||
- `view_count_all` (integer, default: 0)
|
||||
- `view_count_30d` (integer, default: 0)
|
||||
- `view_count_7d` (integer, default: 0)
|
||||
- `average_rating` (numeric, default: 0.00)
|
||||
- `review_count` (integer, default: 0)
|
||||
- `created_at` (timestamptz)
|
||||
- `updated_at` (timestamptz)
|
||||
- `is_test_data` (boolean, default: false)
|
||||
|
||||
### Submission Table: `park_submissions`
|
||||
|
||||
**Schema Identical to Main Table** (excluding auto-generated fields like `id`, timestamps)
|
||||
|
||||
**Additional Field:**
|
||||
- `submission_id` (uuid, NOT NULL, FK → content_submissions)
|
||||
- `temp_location_data` (jsonb) - For pending location creation
|
||||
|
||||
### Version Table: `park_versions`
|
||||
|
||||
**All Main Table Fields PLUS:**
|
||||
- `version_id` (uuid, PK)
|
||||
- `park_id` (uuid, NOT NULL, FK → parks)
|
||||
- `version_number` (integer, NOT NULL)
|
||||
- `change_type` (version_change_type, NOT NULL) - Values: `created`, `updated`, `restored`
|
||||
- `change_reason` (text)
|
||||
- `is_current` (boolean, default: true)
|
||||
- `created_by` (uuid, FK → auth.users)
|
||||
- `created_at` (timestamptz)
|
||||
- `submission_id` (uuid, FK → content_submissions)
|
||||
|
||||
---
|
||||
|
||||
## Rides
|
||||
|
||||
### Main Table: `rides`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `name` (text, NOT NULL)
|
||||
- `slug` (text, NOT NULL, UNIQUE)
|
||||
- `park_id` (uuid, NOT NULL, FK → parks)
|
||||
- `category` (text, NOT NULL) ⚠️ **CRITICAL: This field is required**
|
||||
- Values: `roller_coaster`, `water_ride`, `dark_ride`, `flat_ride`, `transport`, `kids_ride`
|
||||
- `status` (text, NOT NULL)
|
||||
- Values: `operating`, `closed`, `under_construction`, `sbno`, etc.
|
||||
|
||||
**⚠️ IMPORTANT: `rides` table does NOT have `ride_type` column!**
|
||||
- `ride_type` only exists in `ride_models` table
|
||||
- Using `ride_type` in rides updates will cause "column does not exist" error
|
||||
|
||||
**Optional Relationship Fields:**
|
||||
- `manufacturer_id` (uuid, FK → companies)
|
||||
- `designer_id` (uuid, FK → companies)
|
||||
- `ride_model_id` (uuid, FK → ride_models)
|
||||
|
||||
**Optional Descriptive Fields:**
|
||||
- `description` (text)
|
||||
- `opening_date` (date)
|
||||
- `closing_date` (date)
|
||||
- `opening_date_precision` (text)
|
||||
- `closing_date_precision` (text)
|
||||
|
||||
**Optional Technical Fields:**
|
||||
- `height_requirement` (integer) - Height requirement in cm
|
||||
- `age_requirement` (integer)
|
||||
- `max_speed_kmh` (numeric)
|
||||
- `duration_seconds` (integer)
|
||||
- `capacity_per_hour` (integer)
|
||||
- `max_g_force` (numeric)
|
||||
- `inversions` (integer) - Number of inversions
|
||||
- `length_meters` (numeric)
|
||||
- `max_height_meters` (numeric)
|
||||
- `drop_height_meters` (numeric)
|
||||
|
||||
**Category-Specific Fields:**
|
||||
|
||||
*Roller Coasters:*
|
||||
- `ride_sub_type` (text)
|
||||
- `coaster_type` (text)
|
||||
- `seating_type` (text)
|
||||
- `intensity_level` (text)
|
||||
- `track_material` (text)
|
||||
- `support_material` (text)
|
||||
- `propulsion_method` (text)
|
||||
|
||||
*Water Rides:*
|
||||
- `water_depth_cm` (integer)
|
||||
- `splash_height_meters` (numeric)
|
||||
- `wetness_level` (text)
|
||||
- `flume_type` (text)
|
||||
- `boat_capacity` (integer)
|
||||
|
||||
*Dark Rides:*
|
||||
- `theme_name` (text)
|
||||
- `story_description` (text)
|
||||
- `show_duration_seconds` (integer)
|
||||
- `animatronics_count` (integer)
|
||||
- `projection_type` (text)
|
||||
- `ride_system` (text)
|
||||
- `scenes_count` (integer)
|
||||
|
||||
*Flat Rides:*
|
||||
- `rotation_type` (text)
|
||||
- `motion_pattern` (text)
|
||||
- `platform_count` (integer)
|
||||
- `swing_angle_degrees` (numeric)
|
||||
- `rotation_speed_rpm` (numeric)
|
||||
- `arm_length_meters` (numeric)
|
||||
- `max_height_reached_meters` (numeric)
|
||||
|
||||
*Kids Rides:*
|
||||
- `min_age` (integer)
|
||||
- `max_age` (integer)
|
||||
- `educational_theme` (text)
|
||||
- `character_theme` (text)
|
||||
|
||||
*Transport:*
|
||||
- `transport_type` (text)
|
||||
- `route_length_meters` (numeric)
|
||||
- `stations_count` (integer)
|
||||
- `vehicle_capacity` (integer)
|
||||
- `vehicles_count` (integer)
|
||||
- `round_trip_duration_seconds` (integer)
|
||||
|
||||
**Image Fields:**
|
||||
- `banner_image_url` (text)
|
||||
- `banner_image_id` (text)
|
||||
- `card_image_url` (text)
|
||||
- `card_image_id` (text)
|
||||
- `image_url` (text) - Legacy field
|
||||
|
||||
**Metadata Fields:**
|
||||
- `view_count_all` (integer, default: 0)
|
||||
- `view_count_30d` (integer, default: 0)
|
||||
- `view_count_7d` (integer, default: 0)
|
||||
- `average_rating` (numeric, default: 0.00)
|
||||
- `review_count` (integer, default: 0)
|
||||
- `created_at` (timestamptz)
|
||||
- `updated_at` (timestamptz)
|
||||
- `is_test_data` (boolean, default: false)
|
||||
|
||||
### Submission Table: `ride_submissions`
|
||||
|
||||
**Schema Identical to Main Table** (excluding auto-generated fields)
|
||||
|
||||
**Additional Fields:**
|
||||
- `submission_id` (uuid, NOT NULL, FK → content_submissions)
|
||||
|
||||
### Version Table: `ride_versions`
|
||||
|
||||
**All Main Table Fields PLUS:**
|
||||
- `version_id` (uuid, PK)
|
||||
- `ride_id` (uuid, NOT NULL, FK → rides)
|
||||
- `version_number` (integer, NOT NULL)
|
||||
- `change_type` (version_change_type, NOT NULL)
|
||||
- `change_reason` (text)
|
||||
- `is_current` (boolean, default: true)
|
||||
- `created_by` (uuid, FK → auth.users)
|
||||
- `created_at` (timestamptz)
|
||||
- `submission_id` (uuid, FK → content_submissions)
|
||||
|
||||
**⚠️ Field Name Differences (Version Table vs Main Table):**
|
||||
- `height_requirement_cm` in versions → `height_requirement` in rides
|
||||
- `gforce_max` in versions → `max_g_force` in rides
|
||||
- `inversions_count` in versions → `inversions` in rides
|
||||
- `height_meters` in versions → `max_height_meters` in rides
|
||||
- `drop_meters` in versions → `drop_height_meters` in rides
|
||||
|
||||
---
|
||||
|
||||
## Companies
|
||||
|
||||
**Used For**: `manufacturer`, `operator`, `designer`, `property_owner`
|
||||
|
||||
### Main Table: `companies`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `name` (text, NOT NULL)
|
||||
- `slug` (text, NOT NULL, UNIQUE)
|
||||
- `company_type` (text, NOT NULL)
|
||||
- Values: `manufacturer`, `operator`, `designer`, `property_owner`
|
||||
|
||||
**Optional Fields:**
|
||||
- `description` (text)
|
||||
- `person_type` (text, default: 'company')
|
||||
- Values: `company`, `individual`
|
||||
- `founded_year` (integer)
|
||||
- `founded_date` (date)
|
||||
- `founded_date_precision` (text)
|
||||
- `headquarters_location` (text)
|
||||
- `website_url` (text)
|
||||
- `logo_url` (text)
|
||||
- `banner_image_url` (text)
|
||||
- `banner_image_id` (text)
|
||||
- `card_image_url` (text)
|
||||
- `card_image_id` (text)
|
||||
|
||||
**Metadata Fields:**
|
||||
- `view_count_all` (integer, default: 0)
|
||||
- `view_count_30d` (integer, default: 0)
|
||||
- `view_count_7d` (integer, default: 0)
|
||||
- `average_rating` (numeric, default: 0.00)
|
||||
- `review_count` (integer, default: 0)
|
||||
- `created_at` (timestamptz)
|
||||
- `updated_at` (timestamptz)
|
||||
- `is_test_data` (boolean, default: false)
|
||||
|
||||
### Submission Table: `company_submissions`
|
||||
|
||||
**Schema Identical to Main Table** (excluding auto-generated fields)
|
||||
|
||||
**Additional Field:**
|
||||
- `submission_id` (uuid, NOT NULL, FK → content_submissions)
|
||||
|
||||
### Version Table: `company_versions`
|
||||
|
||||
**All Main Table Fields PLUS:**
|
||||
- `version_id` (uuid, PK)
|
||||
- `company_id` (uuid, NOT NULL, FK → companies)
|
||||
- `version_number` (integer, NOT NULL)
|
||||
- `change_type` (version_change_type, NOT NULL)
|
||||
- `change_reason` (text)
|
||||
- `is_current` (boolean, default: true)
|
||||
- `created_by` (uuid, FK → auth.users)
|
||||
- `created_at` (timestamptz)
|
||||
- `submission_id` (uuid, FK → content_submissions)
|
||||
|
||||
---
|
||||
|
||||
## Ride Models
|
||||
|
||||
### Main Table: `ride_models`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `name` (text, NOT NULL)
|
||||
- `slug` (text, NOT NULL, UNIQUE)
|
||||
- `manufacturer_id` (uuid, NOT NULL, FK → companies)
|
||||
- `category` (text, NOT NULL) ⚠️ **CRITICAL: This field is required**
|
||||
- Values: `roller_coaster`, `water_ride`, `dark_ride`, `flat_ride`, `transport`, `kids_ride`
|
||||
|
||||
**Optional Fields:**
|
||||
- `ride_type` (text) ⚠️ **This field exists in ride_models but NOT in rides**
|
||||
- More specific classification than category
|
||||
- Example: category = `roller_coaster`, ride_type = `inverted_coaster`
|
||||
- `description` (text)
|
||||
- `banner_image_url` (text)
|
||||
- `banner_image_id` (text)
|
||||
- `card_image_url` (text)
|
||||
- `card_image_id` (text)
|
||||
|
||||
**Metadata Fields:**
|
||||
- `view_count_all` (integer, default: 0)
|
||||
- `view_count_30d` (integer, default: 0)
|
||||
- `view_count_7d` (integer, default: 0)
|
||||
- `average_rating` (numeric, default: 0.00)
|
||||
- `review_count` (integer, default: 0)
|
||||
- `installations_count` (integer, default: 0)
|
||||
- `created_at` (timestamptz)
|
||||
- `updated_at` (timestamptz)
|
||||
- `is_test_data` (boolean, default: false)
|
||||
|
||||
### Submission Table: `ride_model_submissions`
|
||||
|
||||
**Schema Identical to Main Table** (excluding auto-generated fields)
|
||||
|
||||
**Additional Field:**
|
||||
- `submission_id` (uuid, NOT NULL, FK → content_submissions)
|
||||
|
||||
### Version Table: `ride_model_versions`
|
||||
|
||||
**All Main Table Fields PLUS:**
|
||||
- `version_id` (uuid, PK)
|
||||
- `ride_model_id` (uuid, NOT NULL, FK → ride_models)
|
||||
- `version_number` (integer, NOT NULL)
|
||||
- `change_type` (version_change_type, NOT NULL)
|
||||
- `change_reason` (text)
|
||||
- `is_current` (boolean, default: true)
|
||||
- `created_by` (uuid, FK → auth.users)
|
||||
- `created_at` (timestamptz)
|
||||
- `submission_id` (uuid, FK → content_submissions)
|
||||
|
||||
---
|
||||
|
||||
## Photos
|
||||
|
||||
### Main Table: `photos`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `cloudflare_id` (text, NOT NULL)
|
||||
- `url` (text, NOT NULL)
|
||||
- `entity_type` (text, NOT NULL)
|
||||
- `entity_id` (uuid, NOT NULL)
|
||||
- `uploader_id` (uuid, NOT NULL, FK → auth.users)
|
||||
|
||||
**Optional Fields:**
|
||||
- `title` (text)
|
||||
- `caption` (text)
|
||||
- `taken_date` (date)
|
||||
- `taken_date_precision` (text)
|
||||
- `photographer_name` (text)
|
||||
- `order_index` (integer, default: 0)
|
||||
- `is_primary` (boolean, default: false)
|
||||
- `status` (text, default: 'active')
|
||||
|
||||
**Metadata Fields:**
|
||||
- `created_at` (timestamptz)
|
||||
- `updated_at` (timestamptz)
|
||||
- `is_test_data` (boolean, default: false)
|
||||
|
||||
### Submission Table: `photo_submissions`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `submission_id` (uuid, NOT NULL, FK → content_submissions)
|
||||
- `entity_type` (text, NOT NULL)
|
||||
- `entity_id` (uuid, NOT NULL)
|
||||
- `cloudflare_id` (text, NOT NULL)
|
||||
- `url` (text, NOT NULL)
|
||||
|
||||
**Optional Fields:**
|
||||
- `title` (text)
|
||||
- `caption` (text)
|
||||
- `taken_date` (date)
|
||||
- `taken_date_precision` (text)
|
||||
- `photographer_name` (text)
|
||||
- `order_index` (integer)
|
||||
|
||||
**Note**: Photos do NOT have version tables - they are immutable after approval
|
||||
|
||||
---
|
||||
|
||||
## Timeline Events
|
||||
|
||||
### Main Table: `entity_timeline_events`
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (uuid, PK)
|
||||
- `entity_type` (text, NOT NULL)
|
||||
- `entity_id` (uuid, NOT NULL)
|
||||
- `event_type` (text, NOT NULL)
|
||||
- Values: `opening`, `closing`, `relocation`, `renovation`, `name_change`, `ownership_change`, etc.
|
||||
- `title` (text, NOT NULL)
|
||||
- `event_date` (date, NOT NULL)
|
||||
|
||||
**Optional Fields:**
|
||||
- `description` (text)
|
||||
- `event_date_precision` (text, default: 'day')
|
||||
- `from_value` (text)
|
||||
- `to_value` (text)
|
||||
- `from_entity_id` (uuid)
|
||||
- `to_entity_id` (uuid)
|
||||
- `from_location_id` (uuid)
|
||||
- `to_location_id` (uuid)
|
||||
- `is_public` (boolean, default: true)
|
||||
- `display_order` (integer, default: 0)
|
||||
|
||||
**Approval Fields:**
|
||||
- `created_by` (uuid, FK → auth.users)
|
||||
- `approved_by` (uuid, FK → auth.users)
|
||||
- `submission_id` (uuid, FK → content_submissions)
|
||||
|
||||
**Metadata Fields:**
|
||||
- `created_at` (timestamptz)
|
||||
- `updated_at` (timestamptz)
|
||||
|
||||
### Submission Table: `timeline_event_submissions`
|
||||
|
||||
**Schema Identical to Main Table** (excluding auto-generated fields)
|
||||
|
||||
**Additional Field:**
|
||||
- `submission_id` (uuid, NOT NULL, FK → content_submissions)
|
||||
|
||||
**Note**: Timeline events do NOT have version tables
|
||||
|
||||
---
|
||||
|
||||
## Critical Functions Reference
|
||||
|
||||
### 1. `create_entity_from_submission`
|
||||
|
||||
**Purpose**: Creates new entities from approved submissions
|
||||
|
||||
**Parameters**:
|
||||
- `p_entity_type` (text) - Entity type identifier
|
||||
- `p_data` (jsonb) - Entity data from submission
|
||||
- `p_created_by` (uuid) - User who created it
|
||||
- `p_submission_id` (uuid) - Source submission
|
||||
|
||||
**Critical Requirements**:
|
||||
- ✅ MUST extract `category` for rides and ride_models
|
||||
- ✅ MUST NOT use `ride_type` for rides (doesn't exist)
|
||||
- ✅ MUST use `ride_type` for ride_models (does exist)
|
||||
- ✅ MUST handle all required NOT NULL fields
|
||||
|
||||
**Returns**: `uuid` - New entity ID
|
||||
|
||||
### 2. `update_entity_from_submission`
|
||||
|
||||
**Purpose**: Updates existing entities from approved edits
|
||||
|
||||
**Parameters**:
|
||||
- `p_entity_type` (text) - Entity type identifier
|
||||
- `p_data` (jsonb) - Updated entity data
|
||||
- `p_entity_id` (uuid) - Existing entity ID
|
||||
- `p_changed_by` (uuid) - User who changed it
|
||||
|
||||
**Critical Requirements**:
|
||||
- ✅ MUST use COALESCE to preserve existing values
|
||||
- ✅ MUST include `category` for rides and ride_models
|
||||
- ✅ MUST NOT use `ride_type` for rides
|
||||
- ✅ MUST use `ride_type` for ride_models
|
||||
- ✅ MUST update `updated_at` timestamp
|
||||
|
||||
**Returns**: `uuid` - Updated entity ID
|
||||
|
||||
### 3. `process_approval_transaction`
|
||||
|
||||
**Purpose**: Atomic transaction for selective approval
|
||||
|
||||
**Parameters**:
|
||||
- `p_submission_id` (uuid)
|
||||
- `p_item_ids` (uuid[]) - Specific items to approve
|
||||
- `p_moderator_id` (uuid)
|
||||
- `p_change_reason` (text)
|
||||
|
||||
**Critical Requirements**:
|
||||
- ✅ MUST validate all item dependencies first
|
||||
- ✅ MUST extract correct fields from submission tables
|
||||
- ✅ MUST set session variables for triggers
|
||||
- ✅ MUST handle rollback on any error
|
||||
|
||||
**Called By**: Edge function `process-selective-approval`
|
||||
|
||||
### 4. `create_submission_with_items`
|
||||
|
||||
**Purpose**: Creates multi-item submissions atomically
|
||||
|
||||
**Parameters**:
|
||||
- `p_submission_id` (uuid)
|
||||
- `p_entity_type` (text)
|
||||
- `p_action_type` (text) - `create` or `edit`
|
||||
- `p_items` (jsonb) - Array of submission items
|
||||
- `p_user_id` (uuid)
|
||||
|
||||
**Critical Requirements**:
|
||||
- ✅ MUST resolve dependencies in order
|
||||
- ✅ MUST validate all required fields per entity type
|
||||
- ✅ MUST link items to submission correctly
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. ❌ Using `ride_type` for rides
|
||||
```sql
|
||||
-- WRONG
|
||||
UPDATE rides SET ride_type = 'inverted_coaster' WHERE id = $1;
|
||||
-- ERROR: column "ride_type" does not exist
|
||||
|
||||
-- CORRECT
|
||||
UPDATE rides SET category = 'roller_coaster' WHERE id = $1;
|
||||
```
|
||||
|
||||
### 2. ❌ Missing `category` field
|
||||
```sql
|
||||
-- WRONG - Missing required category
|
||||
INSERT INTO rides (name, slug, park_id, status) VALUES (...);
|
||||
-- ERROR: null value violates not-null constraint
|
||||
|
||||
-- CORRECT
|
||||
INSERT INTO rides (name, slug, park_id, category, status) VALUES (..., 'roller_coaster', ...);
|
||||
```
|
||||
|
||||
### 3. ❌ Wrong column names in version tables
|
||||
```sql
|
||||
-- WRONG
|
||||
SELECT height_requirement FROM ride_versions WHERE ride_id = $1;
|
||||
-- Returns null
|
||||
|
||||
-- CORRECT
|
||||
SELECT height_requirement_cm FROM ride_versions WHERE ride_id = $1;
|
||||
```
|
||||
|
||||
### 4. ❌ Forgetting COALESCE in updates
|
||||
```sql
|
||||
-- WRONG - Overwrites fields with NULL
|
||||
UPDATE rides SET
|
||||
name = (p_data->>'name'),
|
||||
description = (p_data->>'description')
|
||||
WHERE id = $1;
|
||||
|
||||
-- CORRECT - Preserves existing values if not provided
|
||||
UPDATE rides SET
|
||||
name = COALESCE(p_data->>'name', name),
|
||||
description = COALESCE(p_data->>'description', description)
|
||||
WHERE id = $1;
|
||||
```
|
||||
|
||||
### 5. ❌ Not handling submission_id in version triggers
|
||||
```sql
|
||||
-- WRONG - Version doesn't link back to submission
|
||||
INSERT INTO ride_versions (ride_id, ...) VALUES (...);
|
||||
|
||||
-- CORRECT - Trigger must read session variable
|
||||
v_submission_id := current_setting('app.submission_id', true)::uuid;
|
||||
INSERT INTO ride_versions (ride_id, submission_id, ...) VALUES (..., v_submission_id, ...);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before deploying any submission pipeline changes:
|
||||
|
||||
- [ ] All entity tables have matching submission tables
|
||||
- [ ] All required NOT NULL fields are included in CREATE functions
|
||||
- [ ] All required NOT NULL fields are included in UPDATE functions
|
||||
- [ ] `category` is extracted for rides and ride_models
|
||||
- [ ] `ride_type` is NOT used for rides
|
||||
- [ ] `ride_type` IS used for ride_models
|
||||
- [ ] COALESCE is used for all UPDATE statements
|
||||
- [ ] Version table column name differences are handled
|
||||
- [ ] Session variables are set for version triggers
|
||||
- [ ] Foreign key relationships are validated
|
||||
- [ ] Dependency resolution works correctly
|
||||
- [ ] Error handling and rollback logic is present
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
**When adding new entity types:**
|
||||
|
||||
1. Create main table with all fields
|
||||
2. Create matching submission table + `submission_id` FK
|
||||
3. Create version table with all fields + version metadata
|
||||
4. Add case to `create_entity_from_submission`
|
||||
5. Add case to `update_entity_from_submission`
|
||||
6. Add case to `process_approval_transaction`
|
||||
7. Add case to `create_submission_with_items`
|
||||
8. Create version trigger for main table
|
||||
9. Update this documentation
|
||||
10. Run full test suite
|
||||
|
||||
**When modifying schemas:**
|
||||
|
||||
1. Check if field exists in ALL three tables (main, submission, version)
|
||||
2. Update ALL three tables in migration
|
||||
3. Update ALL functions that reference the field
|
||||
4. Update this documentation
|
||||
5. Test create, update, and rollback flows
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Submission Pipeline Overview](./README.md)
|
||||
- [Versioning System](../versioning/README.md)
|
||||
- [Moderation Workflow](../moderation/README.md)
|
||||
- [Migration Guide](../versioning/MIGRATION.md)
|
||||
402
docs/submission-pipeline/VALIDATION_SETUP.md
Normal file
402
docs/submission-pipeline/VALIDATION_SETUP.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Schema Validation Setup Guide
|
||||
|
||||
This guide explains how to set up and use the automated schema validation tools to prevent field mismatches in the submission pipeline.
|
||||
|
||||
## Overview
|
||||
|
||||
The validation system consists of three layers:
|
||||
|
||||
1. **Pre-migration Script** - Quick validation before deploying migrations
|
||||
2. **Integration Tests** - Comprehensive Playwright tests for CI/CD
|
||||
3. **GitHub Actions** - Automated checks on every pull request
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add NPM Scripts
|
||||
|
||||
Add these scripts to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"validate-schema": "tsx scripts/validate-schema.ts",
|
||||
"test:schema": "playwright test schema-validation",
|
||||
"test:schema:ui": "playwright test schema-validation --ui",
|
||||
"pre-migrate": "npm run validate-schema"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Environment Variables
|
||||
|
||||
Create a `.env.test` file:
|
||||
|
||||
```env
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
||||
```
|
||||
|
||||
**⚠️ Important**: Never commit this file! Add it to `.gitignore`:
|
||||
|
||||
```gitignore
|
||||
.env.test
|
||||
.env.local
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
If not already installed:
|
||||
|
||||
```bash
|
||||
npm install --save-dev @supabase/supabase-js @playwright/test tsx
|
||||
```
|
||||
|
||||
## Using the Validation Tools
|
||||
|
||||
### Pre-Migration Validation Script
|
||||
|
||||
**When to use**: Before applying any database migration
|
||||
|
||||
**Run manually:**
|
||||
```bash
|
||||
npm run validate-schema
|
||||
```
|
||||
|
||||
**What it checks:**
|
||||
- ✅ Submission tables match main tables
|
||||
- ✅ Version tables have all required fields
|
||||
- ✅ Critical fields are correct (e.g., `category` vs `ride_type`)
|
||||
- ✅ Database functions exist and are accessible
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
🔍 Starting schema validation...
|
||||
|
||||
Submission Tables:
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
✅ Parks: submission table matches main table
|
||||
✅ Rides: submission table matches main table
|
||||
✅ Companies: submission table matches main table
|
||||
✅ Ride Models: submission table matches main table
|
||||
|
||||
Version Tables:
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
✅ Parks: version table has all fields
|
||||
✅ Rides: version table has all fields
|
||||
✅ Companies: version table has all fields
|
||||
✅ Ride Models: version table has all fields
|
||||
|
||||
Critical Fields:
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
✅ rides table does NOT have ride_type column
|
||||
✅ rides table has category column
|
||||
✅ ride_models has both category and ride_type
|
||||
|
||||
Functions:
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
✅ create_entity_from_submission exists and is accessible
|
||||
✅ update_entity_from_submission exists and is accessible
|
||||
✅ process_approval_transaction exists and is accessible
|
||||
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
Total: 15 passed, 0 failed
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
✅ All schema validations passed. Safe to deploy.
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**When to use**: In CI/CD, before merging PRs, after major changes
|
||||
|
||||
**Run all tests:**
|
||||
```bash
|
||||
npm run test:schema
|
||||
```
|
||||
|
||||
**Run in UI mode (for debugging):**
|
||||
```bash
|
||||
npm run test:schema:ui
|
||||
```
|
||||
|
||||
**Run specific test suite:**
|
||||
```bash
|
||||
npx playwright test schema-validation --grep "Entity Tables"
|
||||
```
|
||||
|
||||
**What it tests:**
|
||||
- All pre-migration script checks PLUS:
|
||||
- Field-by-field data type comparison
|
||||
- NOT NULL constraint validation
|
||||
- Foreign key existence checks
|
||||
- Known field name variations (e.g., `height_requirement_cm` vs `height_requirement`)
|
||||
|
||||
### GitHub Actions (Automated)
|
||||
|
||||
**Automatically runs on:**
|
||||
- Every pull request that touches:
|
||||
- `supabase/migrations/**`
|
||||
- `src/lib/moderation/**`
|
||||
- `supabase/functions/**`
|
||||
- Pushes to `main` or `develop` branches
|
||||
- Manual workflow dispatch
|
||||
|
||||
**What it does:**
|
||||
1. Runs validation script
|
||||
2. Runs integration tests
|
||||
3. Checks for breaking migration patterns
|
||||
4. Validates migration file naming
|
||||
5. Comments on PRs with helpful guidance if tests fail
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Before Creating a Migration
|
||||
|
||||
```bash
|
||||
# 1. Make schema changes locally
|
||||
# 2. Validate before creating migration
|
||||
npm run validate-schema
|
||||
|
||||
# 3. If validation passes, create migration
|
||||
supabase db diff -f add_new_field
|
||||
|
||||
# 4. Run validation again
|
||||
npm run validate-schema
|
||||
|
||||
# 5. Commit and push
|
||||
git add .
|
||||
git commit -m "Add new field to rides table"
|
||||
git push
|
||||
```
|
||||
|
||||
### After Modifying Entity Schemas
|
||||
|
||||
```bash
|
||||
# 1. Modified rides table schema
|
||||
# 2. Run full test suite
|
||||
npm run test:schema
|
||||
|
||||
# 3. Check specific validation
|
||||
npx playwright test schema-validation --grep "rides"
|
||||
|
||||
# 4. Fix any issues
|
||||
# 5. Re-run tests
|
||||
npm run test:schema
|
||||
```
|
||||
|
||||
### During Code Review
|
||||
|
||||
**PR Author:**
|
||||
1. Ensure all validation tests pass locally
|
||||
2. Push changes
|
||||
3. Wait for GitHub Actions to complete
|
||||
4. Address any automated feedback
|
||||
|
||||
**Reviewer:**
|
||||
1. Check that GitHub Actions passed
|
||||
2. Review schema changes in migrations
|
||||
3. Verify documentation was updated
|
||||
4. Approve if all checks pass
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: "Missing fields" Error
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
❌ Rides: submission table matches main table
|
||||
└─ Missing fields: category
|
||||
```
|
||||
|
||||
**Cause**: Field was added to main table but not submission table
|
||||
|
||||
**Solution:**
|
||||
```sql
|
||||
-- In your migration file
|
||||
ALTER TABLE ride_submissions ADD COLUMN category TEXT NOT NULL;
|
||||
```
|
||||
|
||||
### Issue: "Type mismatch" Error
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
❌ Rides: submission table matches main table
|
||||
└─ Type mismatches: max_speed_kmh: main=numeric, submission=integer
|
||||
```
|
||||
|
||||
**Cause**: Data types don't match between tables
|
||||
|
||||
**Solution:**
|
||||
```sql
|
||||
-- In your migration file
|
||||
ALTER TABLE ride_submissions
|
||||
ALTER COLUMN max_speed_kmh TYPE NUMERIC USING max_speed_kmh::numeric;
|
||||
```
|
||||
|
||||
### Issue: "Column does not exist" in Production
|
||||
|
||||
**Symptom**: Approval fails with `column "category" does not exist`
|
||||
|
||||
**Immediate action:**
|
||||
1. Run validation script to identify issue
|
||||
2. Create emergency migration to add missing field
|
||||
3. Deploy immediately
|
||||
4. Update functions if needed
|
||||
|
||||
**Prevention**: Always run validation before deploying
|
||||
|
||||
### Issue: Tests Pass Locally but Fail in CI
|
||||
|
||||
**Possible causes:**
|
||||
- Different database state in CI vs local
|
||||
- Missing environment variables
|
||||
- Outdated schema in test database
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Pull latest schema
|
||||
supabase db pull
|
||||
|
||||
# Reset local database
|
||||
supabase db reset
|
||||
|
||||
# Re-run tests
|
||||
npm run test:schema
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do's
|
||||
|
||||
- ✅ Run validation script before every migration
|
||||
- ✅ Run integration tests before merging PRs
|
||||
- ✅ Update all three tables when adding fields (main, submission, version)
|
||||
- ✅ Document field name variations in tests
|
||||
- ✅ Check GitHub Actions results before merging
|
||||
- ✅ Keep SCHEMA_REFERENCE.md up to date
|
||||
|
||||
### ❌ Don'ts
|
||||
|
||||
- ❌ Don't skip validation "because it's a small change"
|
||||
- ❌ Don't add fields to only main tables
|
||||
- ❌ Don't ignore failing tests
|
||||
- ❌ Don't bypass CI checks
|
||||
- ❌ Don't commit service role keys
|
||||
- ❌ Don't modify submission pipeline functions without testing
|
||||
|
||||
## Continuous Integration Setup
|
||||
|
||||
### GitHub Secrets
|
||||
|
||||
Add to your repository secrets:
|
||||
|
||||
```
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Go to repository Settings → Secrets and variables → Actions
|
||||
2. Click "New repository secret"
|
||||
3. Name: `SUPABASE_SERVICE_ROLE_KEY`
|
||||
4. Value: Your service role key from Supabase dashboard
|
||||
5. Save
|
||||
|
||||
### Branch Protection Rules
|
||||
|
||||
Recommended settings:
|
||||
|
||||
```
|
||||
Branch: main
|
||||
✓ Require status checks to pass before merging
|
||||
✓ validate-schema (Schema Validation)
|
||||
✓ migration-safety-check (Migration Safety Check)
|
||||
✓ Require branches to be up to date before merging
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Script Won't Run
|
||||
|
||||
**Error:** `tsx: command not found`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
npm install -g tsx
|
||||
# or
|
||||
npx tsx scripts/validate-schema.ts
|
||||
```
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
**Error:** `Invalid API key`
|
||||
|
||||
**Solution:**
|
||||
1. Check `.env.test` has correct service role key
|
||||
2. Verify key has not expired
|
||||
3. Ensure environment variable is loaded:
|
||||
```bash
|
||||
source .env.test
|
||||
npm run validate-schema
|
||||
```
|
||||
|
||||
### Tests Timeout
|
||||
|
||||
**Error:** Tests timeout after 30 seconds
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Increase timeout
|
||||
npx playwright test schema-validation --timeout=60000
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Adding New Entity Types
|
||||
|
||||
When adding a new entity type (e.g., `events`):
|
||||
|
||||
1. **Update validation script:**
|
||||
```typescript
|
||||
// In scripts/validate-schema.ts
|
||||
await validateSubmissionTable('events', 'event_submissions', 'Events');
|
||||
await validateVersionTable('events', 'event_versions', 'Events');
|
||||
```
|
||||
|
||||
2. **Update integration tests:**
|
||||
```typescript
|
||||
// In tests/integration/schema-validation.test.ts
|
||||
test('events: submission table matches main table schema', async () => {
|
||||
// Add test logic
|
||||
});
|
||||
```
|
||||
|
||||
3. **Update documentation:**
|
||||
- `docs/submission-pipeline/SCHEMA_REFERENCE.md`
|
||||
- This file (`VALIDATION_SETUP.md`)
|
||||
|
||||
### Updating Field Mappings
|
||||
|
||||
When version tables use different field names:
|
||||
|
||||
```typescript
|
||||
// In both script and tests
|
||||
const fieldMapping: { [key: string]: string } = {
|
||||
'new_main_field': 'version_field_name',
|
||||
};
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Schema Reference](./SCHEMA_REFERENCE.md) - Complete field mappings
|
||||
- [Integration Tests README](../../tests/integration/README.md) - Detailed test documentation
|
||||
- [Submission Pipeline](./README.md) - Pipeline overview
|
||||
- [Versioning System](../versioning/README.md) - Version table details
|
||||
|
||||
## Support
|
||||
|
||||
**Questions?** Check the documentation above or review existing migration files.
|
||||
|
||||
**Found a bug in validation?** Open an issue with:
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
- Validation script output
|
||||
- Database schema snippets
|
||||
332
scripts/validate-schema.ts
Normal file
332
scripts/validate-schema.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Schema Validation Script
|
||||
*
|
||||
* Pre-migration validation script that checks schema consistency
|
||||
* across the submission pipeline before deploying changes.
|
||||
*
|
||||
* Usage:
|
||||
* npm run validate-schema
|
||||
* or
|
||||
* tsx scripts/validate-schema.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = All validations passed
|
||||
* 1 = Validation failures detected
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const SUPABASE_URL = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!SUPABASE_KEY) {
|
||||
console.error('❌ SUPABASE_SERVICE_ROLE_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
||||
|
||||
interface ValidationResult {
|
||||
category: string;
|
||||
test: string;
|
||||
passed: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const results: ValidationResult[] = [];
|
||||
|
||||
async function getTableColumns(tableName: string): Promise<Set<string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('information_schema.columns' as any)
|
||||
.select('column_name')
|
||||
.eq('table_schema', 'public')
|
||||
.eq('table_name', tableName);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return new Set(data?.map((row: any) => row.column_name) || []);
|
||||
}
|
||||
|
||||
async function validateSubmissionTable(
|
||||
mainTable: string,
|
||||
submissionTable: string,
|
||||
entityName: string
|
||||
): Promise<void> {
|
||||
const mainColumns = await getTableColumns(mainTable);
|
||||
const submissionColumns = await getTableColumns(submissionTable);
|
||||
|
||||
const excludedFields = new Set([
|
||||
'id', 'created_at', 'updated_at', 'is_test_data',
|
||||
'view_count_all', 'view_count_30d', 'view_count_7d',
|
||||
'average_rating', 'review_count', 'installations_count',
|
||||
]);
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const field of mainColumns) {
|
||||
if (excludedFields.has(field)) continue;
|
||||
if (!submissionColumns.has(field)) {
|
||||
missingFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFields.length === 0) {
|
||||
results.push({
|
||||
category: 'Submission Tables',
|
||||
test: `${entityName}: submission table matches main table`,
|
||||
passed: true,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
category: 'Submission Tables',
|
||||
test: `${entityName}: submission table matches main table`,
|
||||
passed: false,
|
||||
message: `Missing fields: ${missingFields.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function validateVersionTable(
|
||||
mainTable: string,
|
||||
versionTable: string,
|
||||
entityName: string
|
||||
): Promise<void> {
|
||||
const mainColumns = await getTableColumns(mainTable);
|
||||
const versionColumns = await getTableColumns(versionTable);
|
||||
|
||||
const excludedFields = new Set([
|
||||
'id', 'created_at', 'updated_at', 'is_test_data',
|
||||
'view_count_all', 'view_count_30d', 'view_count_7d',
|
||||
'average_rating', 'review_count', 'installations_count',
|
||||
]);
|
||||
|
||||
const fieldMapping: { [key: string]: string } = {
|
||||
'height_requirement': 'height_requirement_cm',
|
||||
'max_g_force': 'gforce_max',
|
||||
'inversions': 'inversions_count',
|
||||
'max_height_meters': 'height_meters',
|
||||
'drop_height_meters': 'drop_meters',
|
||||
};
|
||||
|
||||
const requiredVersionFields = new Set([
|
||||
'version_id', 'version_number', 'change_type', 'change_reason',
|
||||
'is_current', 'created_by', 'submission_id', 'is_test_data',
|
||||
]);
|
||||
|
||||
const missingMainFields: string[] = [];
|
||||
const missingVersionFields: string[] = [];
|
||||
|
||||
// Check main table fields exist in version table
|
||||
for (const field of mainColumns) {
|
||||
if (excludedFields.has(field)) continue;
|
||||
|
||||
const mappedField = fieldMapping[field] || field;
|
||||
if (!versionColumns.has(field) && !versionColumns.has(mappedField)) {
|
||||
missingMainFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
// Check version metadata fields exist
|
||||
for (const field of requiredVersionFields) {
|
||||
if (!versionColumns.has(field)) {
|
||||
missingVersionFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingMainFields.length === 0 && missingVersionFields.length === 0) {
|
||||
results.push({
|
||||
category: 'Version Tables',
|
||||
test: `${entityName}: version table has all fields`,
|
||||
passed: true,
|
||||
});
|
||||
} else {
|
||||
const messages: string[] = [];
|
||||
if (missingMainFields.length > 0) {
|
||||
messages.push(`Missing main fields: ${missingMainFields.join(', ')}`);
|
||||
}
|
||||
if (missingVersionFields.length > 0) {
|
||||
messages.push(`Missing version fields: ${missingVersionFields.join(', ')}`);
|
||||
}
|
||||
|
||||
results.push({
|
||||
category: 'Version Tables',
|
||||
test: `${entityName}: version table has all fields`,
|
||||
passed: false,
|
||||
message: messages.join('; '),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCriticalFields(): Promise<void> {
|
||||
const ridesColumns = await getTableColumns('rides');
|
||||
const rideModelsColumns = await getTableColumns('ride_models');
|
||||
|
||||
// Rides should NOT have ride_type
|
||||
if (!ridesColumns.has('ride_type')) {
|
||||
results.push({
|
||||
category: 'Critical Fields',
|
||||
test: 'rides table does NOT have ride_type column',
|
||||
passed: true,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
category: 'Critical Fields',
|
||||
test: 'rides table does NOT have ride_type column',
|
||||
passed: false,
|
||||
message: 'rides table incorrectly has ride_type column',
|
||||
});
|
||||
}
|
||||
|
||||
// Rides MUST have category
|
||||
if (ridesColumns.has('category')) {
|
||||
results.push({
|
||||
category: 'Critical Fields',
|
||||
test: 'rides table has category column',
|
||||
passed: true,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
category: 'Critical Fields',
|
||||
test: 'rides table has category column',
|
||||
passed: false,
|
||||
message: 'rides table is missing required category column',
|
||||
});
|
||||
}
|
||||
|
||||
// Ride models must have both category and ride_type
|
||||
if (rideModelsColumns.has('category') && rideModelsColumns.has('ride_type')) {
|
||||
results.push({
|
||||
category: 'Critical Fields',
|
||||
test: 'ride_models has both category and ride_type',
|
||||
passed: true,
|
||||
});
|
||||
} else {
|
||||
const missing: string[] = [];
|
||||
if (!rideModelsColumns.has('category')) missing.push('category');
|
||||
if (!rideModelsColumns.has('ride_type')) missing.push('ride_type');
|
||||
|
||||
results.push({
|
||||
category: 'Critical Fields',
|
||||
test: 'ride_models has both category and ride_type',
|
||||
passed: false,
|
||||
message: `ride_models is missing: ${missing.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function validateFunctions(): Promise<void> {
|
||||
const functionsToCheck = [
|
||||
'create_entity_from_submission',
|
||||
'update_entity_from_submission',
|
||||
'process_approval_transaction',
|
||||
];
|
||||
|
||||
for (const funcName of functionsToCheck) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.rpc('pg_catalog.pg_function_is_visible' as any, {
|
||||
funcid: `public.${funcName}`::any
|
||||
} as any);
|
||||
|
||||
if (!error) {
|
||||
results.push({
|
||||
category: 'Functions',
|
||||
test: `${funcName} exists and is accessible`,
|
||||
passed: true,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
category: 'Functions',
|
||||
test: `${funcName} exists and is accessible`,
|
||||
passed: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({
|
||||
category: 'Functions',
|
||||
test: `${funcName} exists and is accessible`,
|
||||
passed: false,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printResults(): void {
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('Schema Validation Results');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
const categories = [...new Set(results.map(r => r.category))];
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryResults = results.filter(r => r.category === category);
|
||||
const passed = categoryResults.filter(r => r.passed).length;
|
||||
const failed = categoryResults.filter(r => !r.passed).length;
|
||||
|
||||
console.log(`\n${category}:`);
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
for (const result of categoryResults) {
|
||||
const icon = result.passed ? '✅' : '❌';
|
||||
console.log(`${icon} ${result.test}`);
|
||||
if (result.message) {
|
||||
console.log(` └─ ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
totalPassed += passed;
|
||||
totalFailed += failed;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`Total: ${totalPassed} passed, ${totalFailed} failed`);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('🔍 Starting schema validation...\n');
|
||||
|
||||
try {
|
||||
// Validate submission tables
|
||||
await validateSubmissionTable('parks', 'park_submissions', 'Parks');
|
||||
await validateSubmissionTable('rides', 'ride_submissions', 'Rides');
|
||||
await validateSubmissionTable('companies', 'company_submissions', 'Companies');
|
||||
await validateSubmissionTable('ride_models', 'ride_model_submissions', 'Ride Models');
|
||||
|
||||
// Validate version tables
|
||||
await validateVersionTable('parks', 'park_versions', 'Parks');
|
||||
await validateVersionTable('rides', 'ride_versions', 'Rides');
|
||||
await validateVersionTable('companies', 'company_versions', 'Companies');
|
||||
await validateVersionTable('ride_models', 'ride_model_versions', 'Ride Models');
|
||||
|
||||
// Validate critical fields
|
||||
await validateCriticalFields();
|
||||
|
||||
// Validate functions
|
||||
await validateFunctions();
|
||||
|
||||
// Print results
|
||||
printResults();
|
||||
|
||||
// Exit with appropriate code
|
||||
const hasFailures = results.some(r => !r.passed);
|
||||
if (hasFailures) {
|
||||
console.error('❌ Schema validation failed. Please fix the issues above before deploying.\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ All schema validations passed. Safe to deploy.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fatal error during validation:');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -73,6 +73,7 @@ const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
|
||||
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
|
||||
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
|
||||
const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
||||
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
||||
|
||||
// User routes (lazy-loaded)
|
||||
const Profile = lazy(() => import("./pages/Profile"));
|
||||
@@ -387,6 +388,14 @@ function AppContent(): React.JSX.Element {
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/trace-viewer"
|
||||
element={
|
||||
<AdminErrorBoundary section="Trace Viewer">
|
||||
<TraceViewer />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Utility routes - lazy loaded */}
|
||||
<Route path="/force-logout" element={<ForceLogout />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { format } from 'date-fns';
|
||||
@@ -196,6 +197,27 @@ export function ApprovalFailureModal({ failure, onClose }: ApprovalFailureModalP
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
{failure.request_id && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/admin/error-monitoring?tab=edge-functions&requestId=${failure.request_id}`, '_blank')}
|
||||
>
|
||||
View Edge Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/admin/error-monitoring?tab=traces&traceId=${failure.request_id}`, '_blank')}
|
||||
>
|
||||
View Full Trace
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
161
src/components/admin/CorrelatedLogsView.tsx
Normal file
161
src/components/admin/CorrelatedLogsView.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Clock } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
|
||||
interface CorrelatedLogsViewProps {
|
||||
requestId: string;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
interface TimelineEvent {
|
||||
timestamp: Date;
|
||||
type: 'error' | 'edge' | 'database' | 'approval';
|
||||
message: string;
|
||||
severity?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function CorrelatedLogsView({ requestId, traceId }: CorrelatedLogsViewProps) {
|
||||
const { data: events, isLoading } = useQuery({
|
||||
queryKey: ['correlated-logs', requestId, traceId],
|
||||
queryFn: async () => {
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
// Fetch application error
|
||||
const { data: error } = await supabase
|
||||
.from('request_metadata')
|
||||
.select('*')
|
||||
.eq('request_id', requestId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
events.push({
|
||||
timestamp: new Date(error.created_at),
|
||||
type: 'error',
|
||||
message: error.error_message || 'Unknown error',
|
||||
severity: error.error_type || undefined,
|
||||
metadata: {
|
||||
endpoint: error.endpoint,
|
||||
method: error.method,
|
||||
status_code: error.status_code,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch approval metrics
|
||||
const { data: approval } = await supabase
|
||||
.from('approval_transaction_metrics')
|
||||
.select('*')
|
||||
.eq('request_id', requestId)
|
||||
.maybeSingle();
|
||||
|
||||
if (approval && approval.created_at) {
|
||||
events.push({
|
||||
timestamp: new Date(approval.created_at),
|
||||
type: 'approval',
|
||||
message: approval.success ? 'Approval successful' : (approval.error_message || 'Approval failed'),
|
||||
severity: approval.success ? 'success' : 'error',
|
||||
metadata: {
|
||||
items_count: approval.items_count,
|
||||
duration_ms: approval.duration_ms || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Fetch edge function logs (requires Management API access)
|
||||
// TODO: Fetch database logs (requires analytics API access)
|
||||
|
||||
// Sort chronologically
|
||||
events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
return events;
|
||||
},
|
||||
});
|
||||
|
||||
const getTypeColor = (type: string): "default" | "destructive" | "outline" | "secondary" => {
|
||||
switch (type) {
|
||||
case 'error': return 'destructive';
|
||||
case 'approval': return 'destructive';
|
||||
case 'edge': return 'default';
|
||||
case 'database': return 'secondary';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">
|
||||
No correlated logs found for this request.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Timeline for Request {requestId.slice(0, 8)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-4">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
{events.map((event, index) => (
|
||||
<div key={index} className="relative pl-14">
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-[18px] top-2 w-4 h-4 rounded-full bg-background border-2 border-primary" />
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getTypeColor(event.type)}>
|
||||
{event.type.toUpperCase()}
|
||||
</Badge>
|
||||
{event.severity && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{event.severity}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(event.timestamp, 'HH:mm:ss.SSS')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{event.message}</p>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium">{key}:</span> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
172
src/components/admin/DatabaseLogs.tsx
Normal file
172
src/components/admin/DatabaseLogs.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2, Search, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
|
||||
interface DatabaseLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
identifier: string;
|
||||
error_severity: string;
|
||||
event_message: string;
|
||||
}
|
||||
|
||||
export function DatabaseLogs() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [severity, setSeverity] = useState<string>('all');
|
||||
const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d'>('24h');
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
queryKey: ['database-logs', severity, timeRange],
|
||||
queryFn: async () => {
|
||||
// For now, return empty array as we need proper permissions for analytics query
|
||||
// In production, this would use Supabase Analytics API
|
||||
// const hoursAgo = timeRange === '1h' ? 1 : timeRange === '24h' ? 24 : 168;
|
||||
// const startTime = Date.now() * 1000 - (hoursAgo * 60 * 60 * 1000 * 1000);
|
||||
|
||||
return [] as DatabaseLog[];
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const filteredLogs = logs?.filter(log => {
|
||||
if (searchTerm && !log.event_message.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getSeverityColor = (severity: string): "default" | "destructive" | "outline" | "secondary" => {
|
||||
switch (severity.toUpperCase()) {
|
||||
case 'ERROR': return 'destructive';
|
||||
case 'WARNING': return 'destructive';
|
||||
case 'NOTICE': return 'default';
|
||||
case 'LOG': return 'secondary';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const isSpanLog = (message: string) => {
|
||||
return message.includes('SPAN:') || message.includes('SPAN_EVENT:');
|
||||
};
|
||||
|
||||
const toggleExpand = (logId: string) => {
|
||||
setExpandedLog(expandedLog === logId ? null : logId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search database logs..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Severity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="ERROR">Error</SelectItem>
|
||||
<SelectItem value="WARNING">Warning</SelectItem>
|
||||
<SelectItem value="NOTICE">Notice</SelectItem>
|
||||
<SelectItem value="LOG">Log</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as any)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">Last Hour</SelectItem>
|
||||
<SelectItem value="24h">Last 24h</SelectItem>
|
||||
<SelectItem value="7d">Last 7 Days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">
|
||||
No database logs found for the selected criteria.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredLogs.map((log) => (
|
||||
<Card key={log.id} className="overflow-hidden">
|
||||
<CardHeader
|
||||
className="py-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedLog === log.id ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<Badge variant={getSeverityColor(log.error_severity)}>
|
||||
{log.error_severity}
|
||||
</Badge>
|
||||
{isSpanLog(log.event_message) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
TRACE
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(log.timestamp / 1000, 'HH:mm:ss.SSS')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm truncate max-w-[500px]">
|
||||
{log.event_message.slice(0, 100)}
|
||||
{log.event_message.length > 100 && '...'}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{expandedLog === log.id && (
|
||||
<CardContent className="pt-0 pb-4 border-t">
|
||||
<div className="space-y-2 mt-4">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Full Message:</span>
|
||||
<pre className="text-xs font-mono mt-1 whitespace-pre-wrap break-all">
|
||||
{log.event_message}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Timestamp:</span>
|
||||
<p className="text-sm">{format(log.timestamp / 1000, 'PPpp')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Identifier:</span>
|
||||
<p className="text-sm font-mono">{log.identifier}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
src/components/admin/EdgeFunctionLogs.tsx
Normal file
168
src/components/admin/EdgeFunctionLogs.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2, Search, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
|
||||
interface EdgeFunctionLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
event_type: string;
|
||||
event_message: string;
|
||||
function_id: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const FUNCTION_NAMES = [
|
||||
'detect-location',
|
||||
'process-selective-approval',
|
||||
'process-selective-rejection',
|
||||
];
|
||||
|
||||
export function EdgeFunctionLogs() {
|
||||
const [selectedFunction, setSelectedFunction] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d'>('24h');
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
queryKey: ['edge-function-logs', selectedFunction, timeRange],
|
||||
queryFn: async () => {
|
||||
// Query Supabase edge function logs
|
||||
// Note: This uses the analytics endpoint which requires specific permissions
|
||||
const hoursAgo = timeRange === '1h' ? 1 : timeRange === '24h' ? 24 : 168;
|
||||
const startTime = Date.now() - (hoursAgo * 60 * 60 * 1000);
|
||||
|
||||
// For now, return the logs from context as an example
|
||||
// In production, this would call the Supabase Management API
|
||||
const allLogs: EdgeFunctionLog[] = [];
|
||||
|
||||
return allLogs;
|
||||
},
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
const filteredLogs = logs?.filter(log => {
|
||||
if (searchTerm && !log.event_message.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getLevelColor = (level: string): "default" | "destructive" | "secondary" => {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error': return 'destructive';
|
||||
case 'warn': return 'destructive';
|
||||
case 'info': return 'default';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (logId: string) => {
|
||||
setExpandedLog(expandedLog === logId ? null : logId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search logs..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={selectedFunction} onValueChange={setSelectedFunction}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select function" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Functions</SelectItem>
|
||||
{FUNCTION_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as any)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">Last Hour</SelectItem>
|
||||
<SelectItem value="24h">Last 24h</SelectItem>
|
||||
<SelectItem value="7d">Last 7 Days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">
|
||||
No edge function logs found. Logs will appear here when edge functions are invoked.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredLogs.map((log) => (
|
||||
<Card key={log.id} className="overflow-hidden">
|
||||
<CardHeader
|
||||
className="py-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedLog === log.id ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<Badge variant={getLevelColor(log.level)}>
|
||||
{log.level}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(log.timestamp, 'HH:mm:ss.SSS')}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{log.event_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm truncate max-w-[400px]">
|
||||
{log.event_message}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{expandedLog === log.id && (
|
||||
<CardContent className="pt-0 pb-4 border-t">
|
||||
<div className="space-y-2 mt-4">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Full Message:</span>
|
||||
<p className="text-sm font-mono mt-1">{log.event_message}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Timestamp:</span>
|
||||
<p className="text-sm">{format(log.timestamp, 'PPpp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -222,13 +222,31 @@ ${error.error_stack ? `Stack Trace:\n${error.error_stack}` : ''}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/admin/error-monitoring?tab=edge-functions&requestId=${error.request_id}`, '_blank')}
|
||||
>
|
||||
View Edge Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/admin/error-monitoring?tab=database&requestId=${error.request_id}`, '_blank')}
|
||||
>
|
||||
View DB Logs
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={copyErrorReport}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy Report
|
||||
</Button>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
|
||||
import { IntegrationTestRunner as TestRunner, allTestSuites, type TestResult } from '@/lib/integrationTests';
|
||||
import { Play, Square, Download, ChevronDown, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react';
|
||||
import { IntegrationTestRunner as TestRunner, allTestSuites, type TestResult, formatResultsAsMarkdown, formatSingleTestAsMarkdown } from '@/lib/integrationTests';
|
||||
import { Play, Square, Download, ChevronDown, CheckCircle2, XCircle, Clock, SkipForward, Copy, ClipboardX } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
|
||||
@@ -105,6 +105,38 @@ export function IntegrationTestRunner() {
|
||||
toast.success('Test results exported');
|
||||
}, [runner]);
|
||||
|
||||
const copyAllResults = useCallback(async () => {
|
||||
const summary = runner.getSummary();
|
||||
const results = runner.getResults();
|
||||
|
||||
const markdown = formatResultsAsMarkdown(results, summary);
|
||||
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
toast.success('All test results copied to clipboard');
|
||||
}, [runner]);
|
||||
|
||||
const copyFailedTests = useCallback(async () => {
|
||||
const summary = runner.getSummary();
|
||||
const failedResults = runner.getResults().filter(r => r.status === 'fail');
|
||||
|
||||
if (failedResults.length === 0) {
|
||||
toast.info('No failed tests to copy');
|
||||
return;
|
||||
}
|
||||
|
||||
const markdown = formatResultsAsMarkdown(failedResults, summary, true);
|
||||
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
toast.success(`${failedResults.length} failed test(s) copied to clipboard`);
|
||||
}, [runner]);
|
||||
|
||||
const copyTestResult = useCallback(async (result: TestResult) => {
|
||||
const markdown = formatSingleTestAsMarkdown(result);
|
||||
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
toast.success('Test result copied to clipboard');
|
||||
}, []);
|
||||
|
||||
// Guard is handled by the route/page, no loading state needed here
|
||||
|
||||
const summary = runner.getSummary();
|
||||
@@ -166,10 +198,22 @@ export function IntegrationTestRunner() {
|
||||
</Button>
|
||||
)}
|
||||
{results.length > 0 && !isRunning && (
|
||||
<>
|
||||
<Button onClick={exportResults} variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Results
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button onClick={copyAllResults} variant="outline">
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy All
|
||||
</Button>
|
||||
{summary.failed > 0 && (
|
||||
<Button onClick={copyFailedTests} variant="outline">
|
||||
<ClipboardX className="w-4 h-4 mr-2" />
|
||||
Copy Failed ({summary.failed})
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -223,8 +267,10 @@ export function IntegrationTestRunner() {
|
||||
<div className="pt-0.5">
|
||||
{result.status === 'pass' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||
{result.status === 'fail' && <XCircle className="w-4 h-4 text-destructive" />}
|
||||
{result.status === 'skip' && <SkipForward className="w-4 h-4 text-muted-foreground" />}
|
||||
{result.status === 'running' && <Clock className="w-4 h-4 text-blue-500 animate-pulse" />}
|
||||
{result.status === 'skip' && !result.name.includes('⏳') && <SkipForward className="w-4 h-4 text-muted-foreground" />}
|
||||
{result.status === 'skip' && result.name.includes('⏳') && <Clock className="w-4 h-4 text-muted-foreground" />}
|
||||
{result.status === 'running' && !result.name.includes('⏳') && <Clock className="w-4 h-4 text-blue-500 animate-pulse" />}
|
||||
{result.status === 'running' && result.name.includes('⏳') && <Clock className="w-4 h-4 text-amber-500 animate-pulse" />}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -236,6 +282,14 @@ export function IntegrationTestRunner() {
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{result.duration}ms
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => copyTestResult(result)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
{(result.error || result.details) && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
|
||||
203
src/components/admin/UnifiedLogSearch.tsx
Normal file
203
src/components/admin/UnifiedLogSearch.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Search, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
|
||||
interface SearchResult {
|
||||
type: 'error' | 'approval' | 'edge' | 'database';
|
||||
id: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
severity?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface UnifiedLogSearchProps {
|
||||
onNavigate: (tab: string, filters: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function UnifiedLogSearch({ onNavigate }: UnifiedLogSearchProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data: results, isLoading } = useQuery({
|
||||
queryKey: ['unified-log-search', searchTerm],
|
||||
queryFn: async () => {
|
||||
if (!searchTerm) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Search application errors
|
||||
const { data: errors } = await supabase
|
||||
.from('request_metadata')
|
||||
.select('request_id, created_at, error_type, error_message')
|
||||
.or(`request_id.ilike.%${searchTerm}%,error_message.ilike.%${searchTerm}%`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (errors) {
|
||||
results.push(...errors.map(e => ({
|
||||
type: 'error' as const,
|
||||
id: e.request_id,
|
||||
timestamp: e.created_at,
|
||||
message: e.error_message || 'Unknown error',
|
||||
severity: e.error_type || undefined,
|
||||
})));
|
||||
}
|
||||
|
||||
// Search approval failures
|
||||
const { data: approvals } = await supabase
|
||||
.from('approval_transaction_metrics')
|
||||
.select('id, created_at, error_message, request_id')
|
||||
.eq('success', false)
|
||||
.or(`request_id.ilike.%${searchTerm}%,error_message.ilike.%${searchTerm}%`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (approvals) {
|
||||
results.push(...approvals
|
||||
.filter(a => a.created_at)
|
||||
.map(a => ({
|
||||
type: 'approval' as const,
|
||||
id: a.id,
|
||||
timestamp: a.created_at!,
|
||||
message: a.error_message || 'Approval failed',
|
||||
metadata: { request_id: a.request_id },
|
||||
})));
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
return results;
|
||||
},
|
||||
enabled: !!searchTerm,
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchTerm(searchQuery);
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string): "default" | "destructive" | "outline" | "secondary" => {
|
||||
switch (type) {
|
||||
case 'error': return 'destructive';
|
||||
case 'approval': return 'destructive';
|
||||
case 'edge': return 'default';
|
||||
case 'database': return 'secondary';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'Application Error';
|
||||
case 'approval': return 'Approval Failure';
|
||||
case 'edge': return 'Edge Function';
|
||||
case 'database': return 'Database Log';
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
switch (result.type) {
|
||||
case 'error':
|
||||
onNavigate('errors', { requestId: result.id });
|
||||
break;
|
||||
case 'approval':
|
||||
onNavigate('approvals', { failureId: result.id });
|
||||
break;
|
||||
case 'edge':
|
||||
onNavigate('edge-functions', { search: result.message });
|
||||
break;
|
||||
case 'database':
|
||||
onNavigate('database', { search: result.message });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Unified Log Search</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search across all logs (request ID, error message, trace ID...)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={!searchQuery || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{searchTerm && (
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : results && results.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Found {results.length} results
|
||||
</div>
|
||||
{results.map((result) => (
|
||||
<Card
|
||||
key={`${result.type}-${result.id}`}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getTypeColor(result.type)}>
|
||||
{getTypeLabel(result.type)}
|
||||
</Badge>
|
||||
{result.severity && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{result.severity}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(result.timestamp), 'PPp')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{result.message}</p>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
{result.id.slice(0, 16)}...
|
||||
</code>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No results found for "{searchTerm}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function AdminSidebar() {
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
title: 'Error Monitoring',
|
||||
title: 'Monitoring & Logs',
|
||||
url: '/admin/error-monitoring',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ interface RetryStatus {
|
||||
type: string;
|
||||
state: 'retrying' | 'success' | 'failed';
|
||||
errorId?: string;
|
||||
isRateLimit?: boolean;
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,12 +26,22 @@ export function RetryStatusIndicator() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleRetry = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<Omit<RetryStatus, 'state'>>;
|
||||
const { id, attempt, maxAttempts, delay, type } = customEvent.detail;
|
||||
const customEvent = event as CustomEvent<Omit<RetryStatus, 'state' | 'countdown'>>;
|
||||
const { id, attempt, maxAttempts, delay, type, isRateLimit, retryAfter } = customEvent.detail;
|
||||
|
||||
setRetries(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { id, attempt, maxAttempts, delay, type, state: 'retrying', countdown: delay });
|
||||
next.set(id, {
|
||||
id,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
delay,
|
||||
type,
|
||||
state: 'retrying',
|
||||
countdown: delay,
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -161,6 +173,17 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {
|
||||
// Retrying state
|
||||
const progress = retry.delay > 0 ? ((retry.delay - retry.countdown) / retry.delay) * 100 : 0;
|
||||
|
||||
// Customize message based on rate limit status
|
||||
const getMessage = () => {
|
||||
if (retry.isRateLimit) {
|
||||
if (retry.retryAfter) {
|
||||
return `Rate limit reached. Waiting ${Math.ceil(retry.countdown / 1000)}s as requested by server...`;
|
||||
}
|
||||
return `Rate limit reached. Using smart backoff - retrying in ${Math.ceil(retry.countdown / 1000)}s...`;
|
||||
}
|
||||
return `Network issue detected. Retrying ${retry.type} submission in ${Math.ceil(retry.countdown / 1000)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 shadow-lg border-amber-500 bg-amber-50 dark:bg-amber-950 w-80 animate-in slide-in-from-bottom-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -168,7 +191,7 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Retrying submission...
|
||||
{retry.isRateLimit ? 'Rate Limited' : 'Retrying submission...'}
|
||||
</p>
|
||||
<span className="text-xs font-mono text-amber-700 dark:text-amber-300">
|
||||
{retry.attempt}/{retry.maxAttempts}
|
||||
@@ -176,7 +199,7 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Network issue detected. Retrying {retry.type} submission in {Math.ceil(retry.countdown / 1000)}s
|
||||
{getMessage()}
|
||||
</p>
|
||||
|
||||
<Progress value={progress} className="h-1" />
|
||||
|
||||
@@ -52,6 +52,31 @@ export function UppyPhotoSubmissionUpload({
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
/**
|
||||
* ✅ CRITICAL FIX: Cleanup orphaned Cloudflare images
|
||||
* Called when DB transaction fails after successful uploads
|
||||
*/
|
||||
const cleanupOrphanedImages = async (imageIds: string[]) => {
|
||||
if (imageIds.length === 0) return;
|
||||
|
||||
logger.warn('Cleaning up orphaned images', { count: imageIds.length });
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
imageIds.map(id =>
|
||||
invokeWithTracking('upload-image', { action: 'delete', imageId: id }, user?.id)
|
||||
)
|
||||
);
|
||||
logger.info('Orphaned images cleaned up', { count: imageIds.length });
|
||||
} catch (error) {
|
||||
// Non-blocking cleanup - log but don't fail
|
||||
logger.error('Failed to cleanup orphaned images', {
|
||||
error: getErrorMessage(error),
|
||||
imageIds
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilesSelected = (files: File[]) => {
|
||||
// Convert files to photo objects with object URLs for preview
|
||||
const newPhotos: PhotoWithCaption[] = files.map((file, index) => ({
|
||||
@@ -424,6 +449,22 @@ export function UppyPhotoSubmissionUpload({
|
||||
throw photoSubmissionError || new Error("Failed to create photo submission");
|
||||
}
|
||||
|
||||
// ✅ CRITICAL FIX: Create submission_items record for moderation queue
|
||||
const { error: submissionItemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'photo',
|
||||
action_type: 'create',
|
||||
status: 'pending',
|
||||
order_index: 0,
|
||||
photo_submission_id: photoSubmissionData.id
|
||||
});
|
||||
|
||||
if (submissionItemError) {
|
||||
throw submissionItemError;
|
||||
}
|
||||
|
||||
// Insert only successful photo items
|
||||
const photoItems = successfulPhotos.map((photo, index) => ({
|
||||
photo_submission_id: photoSubmissionData.id,
|
||||
@@ -527,6 +568,13 @@ export function UppyPhotoSubmissionUpload({
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = sanitizeErrorMessage(error);
|
||||
|
||||
// ✅ CRITICAL FIX: Cleanup orphaned images on failure
|
||||
if (orphanedCloudflareIds.length > 0) {
|
||||
cleanupOrphanedImages(orphanedCloudflareIds).catch(() => {
|
||||
// Non-blocking - log already handled in cleanupOrphanedImages
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('Photo submission failed', {
|
||||
error: errorMsg,
|
||||
photoCount: photos.length,
|
||||
|
||||
@@ -306,75 +306,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
action: 'approved' | 'rejected';
|
||||
moderatorNotes?: string;
|
||||
}) => {
|
||||
// Handle photo submissions
|
||||
if (action === 'approved' && item.submission_type === 'photo') {
|
||||
const { data: photoSubmission, error: fetchError } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select(`
|
||||
*,
|
||||
items:photo_submission_items(*),
|
||||
submission:content_submissions!inner(user_id)
|
||||
`)
|
||||
.eq('submission_id', item.id)
|
||||
.single();
|
||||
|
||||
// Add explicit error handling
|
||||
if (fetchError) {
|
||||
throw new Error(`Failed to fetch photo submission: ${fetchError.message}`);
|
||||
}
|
||||
|
||||
if (!photoSubmission) {
|
||||
throw new Error('Photo submission not found');
|
||||
}
|
||||
|
||||
// Type assertion with validation
|
||||
const typedPhotoSubmission = photoSubmission as {
|
||||
id: string;
|
||||
entity_id: string;
|
||||
entity_type: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
cloudflare_image_id: string;
|
||||
cloudflare_image_url: string;
|
||||
caption?: string;
|
||||
title?: string;
|
||||
date_taken?: string;
|
||||
date_taken_precision?: string;
|
||||
order_index: number;
|
||||
}>;
|
||||
submission: { user_id: string };
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!typedPhotoSubmission.items || typedPhotoSubmission.items.length === 0) {
|
||||
throw new Error('No photo items found in submission');
|
||||
}
|
||||
|
||||
const { data: existingPhotos } = await supabase
|
||||
.from('photos')
|
||||
.select('id')
|
||||
.eq('submission_id', item.id);
|
||||
|
||||
if (!existingPhotos || existingPhotos.length === 0) {
|
||||
const photoRecords = typedPhotoSubmission.items.map((photoItem) => ({
|
||||
entity_id: typedPhotoSubmission.entity_id,
|
||||
entity_type: typedPhotoSubmission.entity_type,
|
||||
cloudflare_image_id: photoItem.cloudflare_image_id,
|
||||
cloudflare_image_url: photoItem.cloudflare_image_url,
|
||||
title: photoItem.title || null,
|
||||
caption: photoItem.caption || null,
|
||||
date_taken: photoItem.date_taken || null,
|
||||
order_index: photoItem.order_index,
|
||||
submission_id: item.id,
|
||||
submitted_by: typedPhotoSubmission.submission?.user_id,
|
||||
approved_by: user?.id,
|
||||
approved_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
await supabase.from('photos').insert(photoRecords);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for submission items
|
||||
const { data: submissionItems } = await supabase
|
||||
.from('submission_items')
|
||||
@@ -443,15 +374,61 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
});
|
||||
return;
|
||||
} else if (action === 'rejected') {
|
||||
await supabase
|
||||
.from('submission_items')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
rejection_reason: moderatorNotes || 'Parent submission rejected',
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('submission_id', item.id)
|
||||
.eq('status', 'pending');
|
||||
// Use atomic rejection transaction for submission items
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
requestId,
|
||||
attempts,
|
||||
cached,
|
||||
conflictRetries
|
||||
} = await invokeWithResilience(
|
||||
'process-selective-rejection',
|
||||
{
|
||||
itemIds: submissionItems.map((i) => i.id),
|
||||
submissionId: item.id,
|
||||
rejectionReason: moderatorNotes || 'Parent submission rejected',
|
||||
},
|
||||
'rejection',
|
||||
submissionItems.map((i) => i.id),
|
||||
config.user?.id,
|
||||
3, // Max 3 conflict retries
|
||||
30000 // 30s timeout
|
||||
);
|
||||
|
||||
// Log retry attempts
|
||||
if (attempts && attempts > 1) {
|
||||
logger.log(`Rejection succeeded after ${attempts} network retries`, {
|
||||
submissionId: item.id,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (conflictRetries && conflictRetries > 0) {
|
||||
logger.log(`Resolved 409 conflict after ${conflictRetries} retries`, {
|
||||
submissionId: item.id,
|
||||
requestId,
|
||||
cached: !!cached,
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Enhance error with context for better UI feedback
|
||||
if (is409Conflict(error)) {
|
||||
throw new Error(
|
||||
'This rejection is being processed by another request. Please wait and try again if it does not complete.'
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: cached ? 'Cached Result' : 'Submission Rejected',
|
||||
description: cached
|
||||
? `Returned cached result for ${submissionItems.length} item(s)`
|
||||
: `Successfully rejected ${submissionItems.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6345,7 +6345,8 @@ export type Database = {
|
||||
monitor_ban_attempts: { Args: never; Returns: undefined }
|
||||
monitor_failed_submissions: { Args: never; Returns: undefined }
|
||||
monitor_slow_approvals: { Args: never; Returns: undefined }
|
||||
process_approval_transaction: {
|
||||
process_approval_transaction:
|
||||
| {
|
||||
Args: {
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
@@ -6355,6 +6356,41 @@ export type Database = {
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
| {
|
||||
Args: {
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_parent_span_id?: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
p_submitter_id: string
|
||||
p_trace_id?: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
process_rejection_transaction:
|
||||
| {
|
||||
Args: {
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_rejection_reason: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
| {
|
||||
Args: {
|
||||
p_item_ids: string[]
|
||||
p_moderator_id: string
|
||||
p_parent_span_id?: string
|
||||
p_rejection_reason: string
|
||||
p_request_id?: string
|
||||
p_submission_id: string
|
||||
p_trace_id?: string
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
release_expired_locks: { Args: never; Returns: number }
|
||||
release_submission_lock: {
|
||||
Args: { moderator_id: string; submission_id: string }
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function invokeWithTracking<T = any>(
|
||||
timeout: number = 30000,
|
||||
retryOptions?: Partial<RetryOptions>,
|
||||
customHeaders?: Record<string, string>
|
||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number }> {
|
||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number; traceId?: string }> {
|
||||
// Configure retry options with defaults
|
||||
const effectiveRetryOptions: RetryOptions = {
|
||||
maxAttempts: retryOptions?.maxAttempts ?? 3,
|
||||
@@ -75,11 +75,30 @@ export async function invokeWithTracking<T = any>(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// Generate W3C Trace Context header
|
||||
const effectiveTraceId = context.traceId || crypto.randomUUID();
|
||||
const spanId = crypto.randomUUID();
|
||||
const traceparent = `00-${effectiveTraceId}-${spanId}-01`;
|
||||
|
||||
// Add breadcrumb with trace context
|
||||
breadcrumb.apiCall(
|
||||
`/functions/${functionName}`,
|
||||
'POST',
|
||||
undefined
|
||||
);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
||||
body: { ...payload, clientRequestId: context.requestId },
|
||||
body: {
|
||||
...payload,
|
||||
clientRequestId: context.requestId,
|
||||
traceId: effectiveTraceId,
|
||||
},
|
||||
signal: controller.signal,
|
||||
headers: customHeaders,
|
||||
headers: {
|
||||
...customHeaders,
|
||||
'traceparent': traceparent,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -103,7 +122,15 @@ export async function invokeWithTracking<T = any>(
|
||||
}
|
||||
);
|
||||
|
||||
return { data: result, error: null, requestId, duration, attempts: attemptCount, status: 200 };
|
||||
return {
|
||||
data: result,
|
||||
error: null,
|
||||
requestId,
|
||||
duration,
|
||||
attempts: attemptCount,
|
||||
status: 200,
|
||||
traceId,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Handle AbortError specifically
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
@@ -117,6 +144,7 @@ export async function invokeWithTracking<T = any>(
|
||||
duration: timeout,
|
||||
attempts: attemptCount,
|
||||
status: 408,
|
||||
traceId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,9 +156,10 @@ export async function invokeWithTracking<T = any>(
|
||||
duration: 0,
|
||||
attempts: attemptCount,
|
||||
status: (error as any)?.status,
|
||||
traceId: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke multiple edge functions in parallel with batch tracking
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from './logger';
|
||||
import { handleError } from './errorHandler';
|
||||
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
|
||||
import { breadcrumb } from './errorBreadcrumbs';
|
||||
import { isRetryableError } from './retryHelpers';
|
||||
import { isRetryableError, isRateLimitError, extractRetryAfter } from './retryHelpers';
|
||||
import {
|
||||
validateParkCreateFields,
|
||||
validateRideCreateFields,
|
||||
@@ -773,6 +773,8 @@ export async function submitParkCreation(
|
||||
}
|
||||
|
||||
// Create submission with retry logic
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
// Create the main submission record
|
||||
@@ -882,12 +884,30 @@ export async function submitParkCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying park submission', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
// Emit event for UI indicator
|
||||
logger.warn('Retrying park submission', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
// Emit event for UI indicator with rate limit info
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'park' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'park',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -896,18 +916,35 @@ export async function submitParkCreation(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
handleError(error, {
|
||||
).then((data) => {
|
||||
// Emit success event
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
||||
detail: { id: retryId }
|
||||
}));
|
||||
return data;
|
||||
}).catch((error) => {
|
||||
const errorId = handleError(error, {
|
||||
action: 'Park submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
|
||||
// Emit failure event
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
||||
detail: { id: retryId, errorId }
|
||||
}));
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
@@ -1103,17 +1140,31 @@ export async function submitParkUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying park update submission', {
|
||||
attempt,
|
||||
delay,
|
||||
parkId,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
// Emit event for UI retry indicator
|
||||
// Emit event for UI retry indicator with rate limit info
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'park update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -1506,12 +1557,30 @@ export async function submitRideCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying ride submission', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
// Emit event for UI indicator
|
||||
logger.warn('Retrying ride submission', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
// Emit event for UI indicator with rate limit info
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'ride',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -1520,8 +1589,13 @@ export async function submitRideCreation(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
|
||||
return isRetryableError(error);
|
||||
@@ -1714,17 +1788,31 @@ export async function submitRideUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying ride update submission', {
|
||||
attempt,
|
||||
delay,
|
||||
rideId,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
// Emit event for UI retry indicator
|
||||
// Emit event for UI retry indicator with rate limit info
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'ride update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -1733,8 +1821,13 @@ export async function submitRideUpdate(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
if (message.includes('not found')) return false;
|
||||
if (message.includes('not allowed')) return false;
|
||||
}
|
||||
@@ -1838,6 +1931,8 @@ export async function submitRideModelCreation(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
// Create the main submission record
|
||||
@@ -1925,10 +2020,28 @@ export async function submitRideModelCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying ride model submission', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying ride model submission', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'ride_model' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'ride_model',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -1936,12 +2049,36 @@ export async function submitRideModelCreation(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
).then((data) => {
|
||||
// Emit success event
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
||||
detail: { id: retryId }
|
||||
}));
|
||||
return data;
|
||||
}).catch((error) => {
|
||||
const errorId = handleError(error, {
|
||||
action: 'Ride model submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
|
||||
// Emit failure event
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
||||
detail: { id: retryId, errorId }
|
||||
}));
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2006,6 +2143,8 @@ export async function submitRideModelUpdate(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
// Create the main submission record
|
||||
@@ -2091,10 +2230,28 @@ export async function submitRideModelUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying ride model update', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying ride model update', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'ride_model_update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'ride_model_update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2102,12 +2259,34 @@ export async function submitRideModelUpdate(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
).then((data) => {
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
||||
detail: { id: retryId }
|
||||
}));
|
||||
return data;
|
||||
}).catch((error) => {
|
||||
const errorId = handleError(error, {
|
||||
action: 'Ride model update submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
||||
detail: { id: retryId, errorId }
|
||||
}));
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2170,6 +2349,8 @@ export async function submitManufacturerCreation(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2209,10 +2390,28 @@ export async function submitManufacturerCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying manufacturer submission', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying manufacturer submission', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'manufacturer',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2220,12 +2419,34 @@ export async function submitManufacturerCreation(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
).then((data) => {
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
||||
detail: { id: retryId }
|
||||
}));
|
||||
return data;
|
||||
}).catch((error) => {
|
||||
const errorId = handleError(error, {
|
||||
action: 'Manufacturer submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
||||
detail: { id: retryId, errorId }
|
||||
}));
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2283,6 +2504,8 @@ export async function submitManufacturerUpdate(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2320,10 +2543,28 @@ export async function submitManufacturerUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying manufacturer update', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying manufacturer update', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer_update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'manufacturer_update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2394,6 +2635,8 @@ export async function submitDesignerCreation(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2433,10 +2676,28 @@ export async function submitDesignerCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying designer submission', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying designer submission', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'designer' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'designer',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2507,6 +2768,8 @@ export async function submitDesignerUpdate(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2544,10 +2807,28 @@ export async function submitDesignerUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying designer update', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying designer update', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'designer_update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'designer_update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2618,6 +2899,8 @@ export async function submitOperatorCreation(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2657,10 +2940,15 @@ export async function submitOperatorCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying operator submission', { attempt, delay });
|
||||
logger.warn('Retrying operator submission', {
|
||||
attempt,
|
||||
delay,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'operator' }
|
||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'operator' }
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2668,12 +2956,34 @@ export async function submitOperatorCreation(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
).then((data) => {
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
||||
detail: { id: retryId }
|
||||
}));
|
||||
return data;
|
||||
}).catch((error) => {
|
||||
const errorId = handleError(error, {
|
||||
action: 'Operator submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
||||
detail: { id: retryId, errorId }
|
||||
}));
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2731,6 +3041,8 @@ export async function submitOperatorUpdate(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2768,10 +3080,28 @@ export async function submitOperatorUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying operator update', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying operator update', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'operator_update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'operator_update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2842,6 +3172,8 @@ export async function submitPropertyOwnerCreation(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2881,10 +3213,15 @@ export async function submitPropertyOwnerCreation(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying property owner submission', { attempt, delay });
|
||||
logger.warn('Retrying property owner submission', {
|
||||
attempt,
|
||||
delay,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'property_owner' }
|
||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'property_owner' }
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
@@ -2892,12 +3229,34 @@ export async function submitPropertyOwnerCreation(
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('required')) return false;
|
||||
if (message.includes('banned')) return false;
|
||||
if (message.includes('suspended')) return false;
|
||||
if (message.includes('slug')) return false;
|
||||
if (message.includes('already exists')) return false;
|
||||
if (message.includes('duplicate')) return false;
|
||||
if (message.includes('permission')) return false;
|
||||
if (message.includes('forbidden')) return false;
|
||||
if (message.includes('unauthorized')) return false;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
).then((data) => {
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-success', {
|
||||
detail: { id: retryId }
|
||||
}));
|
||||
return data;
|
||||
}).catch((error) => {
|
||||
const errorId = handleError(error, {
|
||||
action: 'Property owner submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
|
||||
detail: { id: retryId, errorId }
|
||||
}));
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2955,6 +3314,8 @@ export async function submitPropertyOwnerUpdate(
|
||||
|
||||
// Submit with retry logic
|
||||
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||
const retryId = crypto.randomUUID();
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
@@ -2992,10 +3353,28 @@ export async function submitPropertyOwnerUpdate(
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying property owner update', { attempt, delay });
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
logger.warn('Retrying property owner update', {
|
||||
attempt,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'property_owner_update' }
|
||||
detail: {
|
||||
id: retryId,
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
delay,
|
||||
type: 'property_owner_update',
|
||||
isRateLimit,
|
||||
retryAfter
|
||||
}
|
||||
}));
|
||||
},
|
||||
shouldRetry: (error) => {
|
||||
|
||||
152
src/lib/integrationTests/formatTestError.ts
Normal file
152
src/lib/integrationTests/formatTestError.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Test Error Formatting Utility
|
||||
*
|
||||
* Provides robust error formatting for test results to avoid "[object Object]" messages
|
||||
* Includes pattern matching for common Supabase/Postgres constraint violations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error pattern matchers for common database constraint violations
|
||||
*/
|
||||
const ERROR_PATTERNS = [
|
||||
{
|
||||
// RLS policy violations
|
||||
pattern: /new row violates row-level security policy for table "(\w+)"/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`RLS Policy Violation: Cannot insert into table "${match[1]}". Check that RLS policies allow this operation and user has proper authentication.`
|
||||
},
|
||||
{
|
||||
// NOT NULL constraint violations
|
||||
pattern: /null value in column "(\w+)" of relation "(\w+)" violates not-null constraint/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`NOT NULL Constraint: Column "${match[1]}" in table "${match[2]}" cannot be null. Provide a value for this required field.`
|
||||
},
|
||||
{
|
||||
// UNIQUE constraint violations
|
||||
pattern: /duplicate key value violates unique constraint "(\w+)"/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`UNIQUE Constraint: Duplicate value violates constraint "${match[1]}". This value already exists in the database.`
|
||||
},
|
||||
{
|
||||
// Foreign key violations
|
||||
pattern: /insert or update on table "(\w+)" violates foreign key constraint "(\w+)"/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Foreign Key Violation: Table "${match[1]}" references non-existent record (constraint: "${match[2]}"). Ensure the referenced entity exists first.`
|
||||
},
|
||||
{
|
||||
// Foreign key violations (alternative format)
|
||||
pattern: /violates foreign key constraint/i,
|
||||
format: () =>
|
||||
`Foreign Key Violation: Referenced record does not exist. Create the parent entity before creating this dependent entity.`
|
||||
},
|
||||
{
|
||||
// Check constraint violations
|
||||
pattern: /new row for relation "(\w+)" violates check constraint "(\w+)"/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Check Constraint: Validation failed for table "${match[1]}" (constraint: "${match[2]}"). The provided value does not meet validation requirements.`
|
||||
},
|
||||
{
|
||||
// Column does not exist
|
||||
pattern: /column "(\w+)" of relation "(\w+)" does not exist/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Schema Error: Column "${match[1]}" does not exist in table "${match[2]}". Check database schema or migration status.`
|
||||
},
|
||||
{
|
||||
// Could not find column in schema cache
|
||||
pattern: /Could not find the '(\w+)' column of '(\w+)' in the schema cache/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Schema Cache Error: Column "${match[1]}" not found in table "${match[2]}". The schema may have changed - try refreshing the database connection.`
|
||||
},
|
||||
{
|
||||
// Table does not exist
|
||||
pattern: /relation "(\w+)" does not exist/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Schema Error: Table "${match[1]}" does not exist. Run migrations or check database schema.`
|
||||
},
|
||||
{
|
||||
// Permission denied
|
||||
pattern: /permission denied for (?:table|relation) "?(\w+)"?/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Permission Denied: Insufficient permissions to access table "${match[1]}". Check RLS policies and user roles.`
|
||||
},
|
||||
{
|
||||
// Rate limit errors
|
||||
pattern: /Rate limit exceeded\. Please wait (\d+) seconds?/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Rate Limited: Too many requests. Wait ${match[1]} seconds before retrying.`
|
||||
},
|
||||
{
|
||||
// Rate limit errors (alternative format)
|
||||
pattern: /Too many submissions in a short time\. Please wait (\d+) seconds?/i,
|
||||
format: (match: RegExpMatchArray) =>
|
||||
`Rate Limited: Submission throttled. Wait ${match[1]} seconds before submitting again.`
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Format error for test result display
|
||||
* Handles Error objects, PostgresError objects, and plain objects
|
||||
*
|
||||
* @param error - Any error value thrown in a test
|
||||
* @returns Formatted, human-readable error string
|
||||
*/
|
||||
export function formatTestError(error: unknown): string {
|
||||
let errorMessage = '';
|
||||
|
||||
// Extract base error message
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === 'object' && error !== null) {
|
||||
const err = error as any;
|
||||
|
||||
// Try common error message properties
|
||||
if (err.message && typeof err.message === 'string') {
|
||||
errorMessage = err.message;
|
||||
|
||||
// Include additional Supabase error details if present
|
||||
if (err.details && typeof err.details === 'string') {
|
||||
errorMessage += ` | Details: ${err.details}`;
|
||||
}
|
||||
if (err.hint && typeof err.hint === 'string') {
|
||||
errorMessage += ` | Hint: ${err.hint}`;
|
||||
}
|
||||
if (err.code && typeof err.code === 'string') {
|
||||
errorMessage += ` | Code: ${err.code}`;
|
||||
}
|
||||
}
|
||||
// Some errors nest the actual error in an 'error' property
|
||||
else if (err.error) {
|
||||
return formatTestError(err.error);
|
||||
}
|
||||
// Some APIs use 'msg' instead of 'message'
|
||||
else if (err.msg && typeof err.msg === 'string') {
|
||||
errorMessage = err.msg;
|
||||
}
|
||||
// Last resort: stringify the entire object
|
||||
else {
|
||||
try {
|
||||
const stringified = JSON.stringify(error, null, 2);
|
||||
errorMessage = stringified.length > 500
|
||||
? stringified.substring(0, 500) + '... (truncated)'
|
||||
: stringified;
|
||||
} catch {
|
||||
// JSON.stringify can fail on circular references
|
||||
errorMessage = String(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Primitive values (strings, numbers, etc.)
|
||||
errorMessage = String(error);
|
||||
}
|
||||
|
||||
// Apply pattern matching to format known constraint violations
|
||||
for (const { pattern, format } of ERROR_PATTERNS) {
|
||||
const match = errorMessage.match(pattern);
|
||||
if (match) {
|
||||
return format(match);
|
||||
}
|
||||
}
|
||||
|
||||
// Return original message if no patterns matched
|
||||
return errorMessage;
|
||||
}
|
||||
76
src/lib/integrationTests/formatters.ts
Normal file
76
src/lib/integrationTests/formatters.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Test Result Formatters
|
||||
*
|
||||
* Utilities for formatting test results into different formats for easy sharing and debugging.
|
||||
*/
|
||||
|
||||
import type { TestResult } from './testRunner';
|
||||
|
||||
export function formatResultsAsMarkdown(
|
||||
results: TestResult[],
|
||||
summary: { total: number; passed: number; failed: number; skipped: number; totalDuration: number },
|
||||
failedOnly: boolean = false
|
||||
): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const title = failedOnly ? 'Failed Test Results' : 'Test Results';
|
||||
|
||||
let markdown = `# ${title} - ${timestamp}\n\n`;
|
||||
|
||||
// Summary section
|
||||
markdown += `## Summary\n`;
|
||||
markdown += `✅ Passed: ${summary.passed}\n`;
|
||||
markdown += `❌ Failed: ${summary.failed}\n`;
|
||||
markdown += `⏭️ Skipped: ${summary.skipped}\n`;
|
||||
markdown += `⏱️ Duration: ${(summary.totalDuration / 1000).toFixed(2)}s\n\n`;
|
||||
|
||||
// Results by status
|
||||
if (!failedOnly && summary.failed > 0) {
|
||||
markdown += `## Failed Tests\n\n`;
|
||||
results.filter(r => r.status === 'fail').forEach(result => {
|
||||
markdown += formatTestResultMarkdown(result);
|
||||
});
|
||||
}
|
||||
|
||||
if (failedOnly) {
|
||||
results.forEach(result => {
|
||||
markdown += formatTestResultMarkdown(result);
|
||||
});
|
||||
} else {
|
||||
// Include passed tests in summary
|
||||
if (summary.passed > 0) {
|
||||
markdown += `## Passed Tests\n\n`;
|
||||
results.filter(r => r.status === 'pass').forEach(result => {
|
||||
markdown += `### ✅ ${result.name} (${result.suite})\n`;
|
||||
markdown += `**Duration:** ${result.duration}ms\n\n`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export function formatSingleTestAsMarkdown(result: TestResult): string {
|
||||
return formatTestResultMarkdown(result);
|
||||
}
|
||||
|
||||
function formatTestResultMarkdown(result: TestResult): string {
|
||||
const icon = result.status === 'fail' ? '❌' : result.status === 'pass' ? '✅' : '⏭️';
|
||||
|
||||
let markdown = `### ${icon} ${result.name} (${result.suite})\n`;
|
||||
markdown += `**Duration:** ${result.duration}ms\n`;
|
||||
markdown += `**Status:** ${result.status}\n`;
|
||||
|
||||
if (result.error) {
|
||||
markdown += `**Error:** ${result.error}\n\n`;
|
||||
}
|
||||
|
||||
if (result.stack) {
|
||||
markdown += `**Stack Trace:**\n\`\`\`\n${result.stack}\n\`\`\`\n\n`;
|
||||
}
|
||||
|
||||
if (result.details) {
|
||||
markdown += `**Details:**\n\`\`\`json\n${JSON.stringify(result.details, null, 2)}\n\`\`\`\n\n`;
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
697
src/lib/integrationTests/helpers/approvalTestHelpers.ts
Normal file
697
src/lib/integrationTests/helpers/approvalTestHelpers.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Approval Pipeline Test Helpers
|
||||
*
|
||||
* Reusable helper functions for approval pipeline integration tests.
|
||||
* These helpers abstract common patterns for submission creation, approval,
|
||||
* and verification across all entity types.
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { TestDataTracker } from '../TestDataTracker';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
import {
|
||||
submitParkCreation,
|
||||
submitRideCreation,
|
||||
submitManufacturerCreation,
|
||||
submitOperatorCreation,
|
||||
submitDesignerCreation,
|
||||
submitPropertyOwnerCreation,
|
||||
submitRideModelCreation
|
||||
} from '@/lib/entitySubmissionHelpers';
|
||||
|
||||
// Re-export formatTestError for use in test suites
|
||||
export { formatTestError } from '../formatTestError';
|
||||
|
||||
// ============================================
|
||||
// AUTHENTICATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current user auth token for edge function calls
|
||||
*/
|
||||
export async function getAuthToken(): Promise<string> {
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
if (error || !session) {
|
||||
throw new Error('Not authenticated - cannot run approval tests');
|
||||
}
|
||||
return session.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID
|
||||
*/
|
||||
export async function getCurrentUserId(): Promise<string> {
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
if (error || !user) {
|
||||
throw new Error('Not authenticated - cannot get user ID');
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EDGE FUNCTION CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get edge function base URL (hardcoded per project requirements)
|
||||
*/
|
||||
export function getEdgeFunctionUrl(): string {
|
||||
return 'https://api.thrillwiki.com/functions/v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Supabase anon key (hardcoded per project requirements)
|
||||
*/
|
||||
export function getSupabaseAnonKey(): string {
|
||||
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRka2VueWdwcHlzZ3NlcmJ5aW9hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mjg0ODY0MTIsImV4cCI6MjA0NDA2MjQxMn0.0qfDbOvh-Hs5n7HHZ0cRQzH5oEL_1D7kj7v6nh4PqgI';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TEST DATA GENERATORS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate unique park submission data
|
||||
*/
|
||||
export function generateUniqueParkData(testId: string): any {
|
||||
const timestamp = Date.now();
|
||||
const slug = `test-park-${testId}-${timestamp}`;
|
||||
|
||||
return {
|
||||
name: `Test Park ${testId} ${timestamp}`,
|
||||
slug,
|
||||
description: `Test park for ${testId}`,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
opening_date: '2000-01-01',
|
||||
opening_date_precision: 'year',
|
||||
location: {
|
||||
name: 'Test Location',
|
||||
city: 'Test City',
|
||||
country: 'US',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
},
|
||||
is_test_data: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ride submission data
|
||||
*/
|
||||
export function generateUniqueRideData(parkId: string, testId: string): any {
|
||||
const timestamp = Date.now();
|
||||
const slug = `test-ride-${testId}-${timestamp}`;
|
||||
|
||||
return {
|
||||
name: `Test Ride ${testId} ${timestamp}`,
|
||||
slug,
|
||||
description: `Test ride for ${testId}`,
|
||||
category: 'roller_coaster',
|
||||
status: 'operating',
|
||||
park_id: parkId,
|
||||
opening_date: '2005-01-01',
|
||||
opening_date_precision: 'year',
|
||||
max_speed_kmh: 100,
|
||||
max_height_meters: 50,
|
||||
length_meters: 1000,
|
||||
is_test_data: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique company submission data
|
||||
*/
|
||||
export function generateUniqueCompanyData(companyType: string, testId: string): any {
|
||||
const timestamp = Date.now();
|
||||
const slug = `test-${companyType}-${testId}-${timestamp}`;
|
||||
|
||||
return {
|
||||
name: `Test ${companyType} ${testId} ${timestamp}`,
|
||||
slug,
|
||||
description: `Test ${companyType} for ${testId}`,
|
||||
person_type: 'company',
|
||||
founded_year: 1990,
|
||||
is_test_data: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ride model submission data
|
||||
*/
|
||||
export function generateUniqueRideModelData(manufacturerId: string, testId: string): any {
|
||||
const timestamp = Date.now();
|
||||
const slug = `test-model-${testId}-${timestamp}`;
|
||||
|
||||
return {
|
||||
name: `Test Model ${testId} ${timestamp}`,
|
||||
slug,
|
||||
manufacturer_id: manufacturerId,
|
||||
category: 'roller_coaster',
|
||||
ride_type: 'steel',
|
||||
description: `Test ride model for ${testId}`,
|
||||
is_test_data: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUBMISSION CREATION HELPERS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a test park submission
|
||||
*/
|
||||
export async function createTestParkSubmission(
|
||||
data: any,
|
||||
userId: string,
|
||||
tracker: TestDataTracker
|
||||
): Promise<{ submissionId: string; itemId: string }> {
|
||||
const result = await submitParkCreation(data, userId);
|
||||
|
||||
if (!result.submissionId) {
|
||||
throw new Error('Park submission creation failed - no submission ID returned');
|
||||
}
|
||||
|
||||
// Track submission for cleanup
|
||||
tracker.track('content_submissions', result.submissionId);
|
||||
|
||||
// Get the submission item ID
|
||||
const { data: items } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id')
|
||||
.eq('submission_id', result.submissionId)
|
||||
.single();
|
||||
|
||||
if (!items?.id) {
|
||||
throw new Error('Failed to get submission item ID');
|
||||
}
|
||||
|
||||
tracker.track('submission_items', items.id);
|
||||
|
||||
return {
|
||||
submissionId: result.submissionId,
|
||||
itemId: items.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test ride submission
|
||||
*/
|
||||
export async function createTestRideSubmission(
|
||||
data: any,
|
||||
userId: string,
|
||||
tracker: TestDataTracker
|
||||
): Promise<{ submissionId: string; itemId: string }> {
|
||||
const result = await submitRideCreation(data, userId);
|
||||
|
||||
if (!result.submissionId) {
|
||||
throw new Error('Ride submission creation failed - no submission ID returned');
|
||||
}
|
||||
|
||||
tracker.track('content_submissions', result.submissionId);
|
||||
|
||||
const { data: items } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id')
|
||||
.eq('submission_id', result.submissionId)
|
||||
.single();
|
||||
|
||||
if (!items?.id) {
|
||||
throw new Error('Failed to get submission item ID');
|
||||
}
|
||||
|
||||
tracker.track('submission_items', items.id);
|
||||
|
||||
return {
|
||||
submissionId: result.submissionId,
|
||||
itemId: items.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test company submission
|
||||
*/
|
||||
export async function createTestCompanySubmission(
|
||||
companyType: 'manufacturer' | 'operator' | 'designer' | 'property_owner',
|
||||
data: any,
|
||||
userId: string,
|
||||
tracker: TestDataTracker
|
||||
): Promise<{ submissionId: string; itemId: string }> {
|
||||
// Call the appropriate company type-specific submission function
|
||||
let result: { submitted: boolean; submissionId: string };
|
||||
|
||||
switch (companyType) {
|
||||
case 'manufacturer':
|
||||
result = await submitManufacturerCreation(data, userId);
|
||||
break;
|
||||
case 'operator':
|
||||
result = await submitOperatorCreation(data, userId);
|
||||
break;
|
||||
case 'designer':
|
||||
result = await submitDesignerCreation(data, userId);
|
||||
break;
|
||||
case 'property_owner':
|
||||
result = await submitPropertyOwnerCreation(data, userId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown company type: ${companyType}`);
|
||||
}
|
||||
|
||||
if (!result.submissionId) {
|
||||
throw new Error('Company submission creation failed - no submission ID returned');
|
||||
}
|
||||
|
||||
tracker.track('content_submissions', result.submissionId);
|
||||
|
||||
const { data: items } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id')
|
||||
.eq('submission_id', result.submissionId)
|
||||
.single();
|
||||
|
||||
if (!items?.id) {
|
||||
throw new Error('Failed to get submission item ID');
|
||||
}
|
||||
|
||||
tracker.track('submission_items', items.id);
|
||||
|
||||
return {
|
||||
submissionId: result.submissionId,
|
||||
itemId: items.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test ride model submission
|
||||
*/
|
||||
export async function createTestRideModelSubmission(
|
||||
data: any,
|
||||
userId: string,
|
||||
tracker: TestDataTracker
|
||||
): Promise<{ submissionId: string; itemId: string }> {
|
||||
const result = await submitRideModelCreation(data, userId);
|
||||
|
||||
if (!result.submissionId) {
|
||||
throw new Error('Ride model submission creation failed - no submission ID returned');
|
||||
}
|
||||
|
||||
tracker.track('content_submissions', result.submissionId);
|
||||
|
||||
const { data: items } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id')
|
||||
.eq('submission_id', result.submissionId)
|
||||
.single();
|
||||
|
||||
if (!items?.id) {
|
||||
throw new Error('Failed to get submission item ID');
|
||||
}
|
||||
|
||||
tracker.track('submission_items', items.id);
|
||||
|
||||
return {
|
||||
submissionId: result.submissionId,
|
||||
itemId: items.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a composite submission with dependencies
|
||||
*/
|
||||
export async function createCompositeSubmission(
|
||||
primaryEntity: { type: 'park' | 'ride'; data: any },
|
||||
dependencies: Array<{ type: string; data: any; tempId: string; companyType?: string }>,
|
||||
userId: string,
|
||||
tracker: TestDataTracker
|
||||
): Promise<{ submissionId: string; itemIds: string[] }> {
|
||||
// Create main submission
|
||||
const { data: submission, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: primaryEntity.type === 'park' ? 'park' : 'ride',
|
||||
status: 'pending',
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (submissionError || !submission) {
|
||||
throw new Error(`Failed to create submission: ${submissionError?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('content_submissions', submission.id);
|
||||
|
||||
const itemIds: string[] = [];
|
||||
|
||||
// Note: This is a simplified composite submission creation
|
||||
// In reality, the actual implementation uses specialized submission tables
|
||||
// (park_submissions, company_submissions, etc.) which are more complex
|
||||
// For testing purposes, we'll track items but note this is incomplete
|
||||
|
||||
// Track submission for cleanup
|
||||
itemIds.push(submission.id);
|
||||
|
||||
return {
|
||||
submissionId: submission.id,
|
||||
itemIds,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPROVAL INVOCATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Approve submission via edge function
|
||||
*/
|
||||
export async function approveSubmission(
|
||||
submissionId: string,
|
||||
itemIds: string[],
|
||||
authToken: string,
|
||||
idempotencyKey?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
error?: string;
|
||||
duration: number;
|
||||
}> {
|
||||
const startTime = performance.now();
|
||||
|
||||
const key = idempotencyKey || `test-${Date.now()}-${Math.random()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getEdgeFunctionUrl()}/process-selective-approval`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': getSupabaseAnonKey(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
submissionId,
|
||||
itemIds,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${errorText}`,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: result.status || 'approved',
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
return {
|
||||
success: false,
|
||||
error: formatTestError(error),
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POLLING & VERIFICATION
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Poll for entity creation
|
||||
*/
|
||||
export async function pollForEntity(
|
||||
table: 'parks' | 'rides' | 'companies' | 'ride_models',
|
||||
id: string,
|
||||
maxWaitMs: number = 10000
|
||||
): Promise<any | null> {
|
||||
const pollInterval = 200;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const { data, error } = await supabase
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (data && !error) {
|
||||
return data;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for version creation
|
||||
*/
|
||||
export async function pollForVersion(
|
||||
entityType: 'park' | 'ride' | 'company' | 'ride_model',
|
||||
entityId: string,
|
||||
expectedVersionNumber: number,
|
||||
maxWaitMs: number = 10000
|
||||
): Promise<any | null> {
|
||||
const versionTable = `${entityType}_versions` as 'park_versions' | 'ride_versions' | 'company_versions' | 'ride_model_versions';
|
||||
const pollInterval = 200;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const { data, error } = await supabase
|
||||
.from(versionTable)
|
||||
.select('*')
|
||||
.eq(`${entityType}_id`, entityId)
|
||||
.eq('version_number', expectedVersionNumber)
|
||||
.single();
|
||||
|
||||
if (data && !error) {
|
||||
return data;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify submission item is approved
|
||||
*/
|
||||
export async function verifySubmissionItemApproved(
|
||||
itemId: string
|
||||
): Promise<{ approved: boolean; entityId: string | null; error?: string }> {
|
||||
const { data, error } = await supabase
|
||||
.from('submission_items')
|
||||
.select('status, approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return { approved: false, entityId: null, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
approved: data.status === 'approved' && !!data.approved_entity_id,
|
||||
entityId: data.approved_entity_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify submission status
|
||||
*/
|
||||
export async function verifySubmissionStatus(
|
||||
submissionId: string,
|
||||
expectedStatus: 'approved' | 'partially_approved' | 'pending'
|
||||
): Promise<boolean> {
|
||||
const { data, error } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('status')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return data.status === expectedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity directly (bypass moderation for setup)
|
||||
*/
|
||||
export async function createParkDirectly(
|
||||
data: any,
|
||||
tracker: TestDataTracker
|
||||
): Promise<string> {
|
||||
// First create location if provided
|
||||
let locationId: string | undefined;
|
||||
|
||||
if (data.location) {
|
||||
const { data: location, error: locError } = await supabase
|
||||
.from('locations')
|
||||
.insert({
|
||||
name: data.location.name,
|
||||
city: data.location.city,
|
||||
country: data.location.country,
|
||||
latitude: data.location.latitude,
|
||||
longitude: data.location.longitude,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (locError || !location) {
|
||||
throw new Error(`Failed to create location: ${locError?.message}`);
|
||||
}
|
||||
|
||||
locationId = location.id;
|
||||
tracker.track('locations', locationId);
|
||||
}
|
||||
|
||||
const parkData = { ...data };
|
||||
delete parkData.location;
|
||||
if (locationId) {
|
||||
parkData.location_id = locationId;
|
||||
}
|
||||
|
||||
const { data: park, error } = await supabase
|
||||
.from('parks')
|
||||
.insert(parkData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !park) {
|
||||
throw new Error(`Failed to create park directly: ${error?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('parks', park.id);
|
||||
return park.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ride directly (bypass moderation for setup)
|
||||
*/
|
||||
export async function createRideDirectly(
|
||||
data: any,
|
||||
tracker: TestDataTracker
|
||||
): Promise<string> {
|
||||
const { data: ride, error } = await supabase
|
||||
.from('rides')
|
||||
.insert(data)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !ride) {
|
||||
throw new Error(`Failed to create ride directly: ${error?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('rides', ride.id);
|
||||
return ride.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test photo gallery submission
|
||||
*/
|
||||
export async function createTestPhotoGallerySubmission(
|
||||
entityId: string,
|
||||
entityType: 'park' | 'ride',
|
||||
photoCount: number,
|
||||
userId: string,
|
||||
tracker: TestDataTracker
|
||||
): Promise<{ submissionId: string; itemId: string }> {
|
||||
// Create content submission first
|
||||
const { data: submission, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'photo_gallery',
|
||||
status: 'pending',
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (submissionError || !submission) {
|
||||
throw new Error(`Failed to create content submission: ${submissionError?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('content_submissions', submission.id);
|
||||
|
||||
// Create photo submission
|
||||
const { data: photoSubmission, error: photoSubError } = await supabase
|
||||
.from('photo_submissions')
|
||||
.insert({
|
||||
entity_id: entityId,
|
||||
entity_type: entityType,
|
||||
submission_id: submission.id,
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (photoSubError || !photoSubmission) {
|
||||
throw new Error(`Failed to create photo submission: ${photoSubError?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('photo_submissions', photoSubmission.id);
|
||||
|
||||
// Create submission item linking to photo submission
|
||||
const { data: item, error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submission.id,
|
||||
photo_submission_id: photoSubmission.id,
|
||||
item_type: 'photo_gallery',
|
||||
status: 'pending',
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (itemError || !item) {
|
||||
throw new Error(`Failed to create submission item: ${itemError?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('submission_items', item.id);
|
||||
|
||||
// Create photo submission items
|
||||
for (let i = 0; i < photoCount; i++) {
|
||||
const { data: photoItem, error: photoItemError } = await supabase
|
||||
.from('photo_submission_items')
|
||||
.insert({
|
||||
photo_submission_id: photoSubmission.id,
|
||||
cloudflare_image_id: `test-image-${Date.now()}-${i}`,
|
||||
cloudflare_image_url: `https://test.com/image-${i}.jpg`,
|
||||
caption: `Test photo ${i + 1}`,
|
||||
order_index: i,
|
||||
is_test_data: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (photoItemError || !photoItem) {
|
||||
throw new Error(`Failed to create photo item ${i}: ${photoItemError?.message}`);
|
||||
}
|
||||
|
||||
tracker.track('photo_submission_items', photoItem.id);
|
||||
}
|
||||
|
||||
return {
|
||||
submissionId: submission.id,
|
||||
itemId: item.id,
|
||||
};
|
||||
}
|
||||
@@ -6,5 +6,7 @@
|
||||
|
||||
export { IntegrationTestRunner } from './testRunner';
|
||||
export { allTestSuites } from './suites';
|
||||
export { formatResultsAsMarkdown, formatSingleTestAsMarkdown } from './formatters';
|
||||
export { formatTestError } from './formatTestError';
|
||||
|
||||
export type { TestResult, Test, TestSuite } from './testRunner';
|
||||
|
||||
1895
src/lib/integrationTests/suites/approvalPipelineTests.ts
Normal file
1895
src/lib/integrationTests/suites/approvalPipelineTests.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const authTestSuite: TestSuite = {
|
||||
id: 'auth',
|
||||
@@ -64,7 +65,7 @@ export const authTestSuite: TestSuite = {
|
||||
suite: 'Authentication & Authorization',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -137,7 +138,7 @@ export const authTestSuite: TestSuite = {
|
||||
suite: 'Authentication & Authorization',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -187,7 +188,7 @@ export const authTestSuite: TestSuite = {
|
||||
suite: 'Authentication & Authorization',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -248,7 +249,7 @@ export const authTestSuite: TestSuite = {
|
||||
suite: 'Authentication & Authorization',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { TestDataTracker } from '../TestDataTracker';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const dataIntegrityTestSuite: TestSuite = {
|
||||
id: 'data-integrity',
|
||||
@@ -77,7 +78,7 @@ export const dataIntegrityTestSuite: TestSuite = {
|
||||
suite: 'Data Integrity & Constraints',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -139,7 +140,7 @@ export const dataIntegrityTestSuite: TestSuite = {
|
||||
suite: 'Data Integrity & Constraints',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -149,52 +150,69 @@ export const dataIntegrityTestSuite: TestSuite = {
|
||||
{
|
||||
id: 'integrity-003',
|
||||
name: 'Unique Constraint Enforcement',
|
||||
description: 'Tests unique constraints prevent duplicate slugs',
|
||||
description: 'Tests unique constraints prevent duplicate slugs via approval pipeline',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create a park
|
||||
const slug = `unique-test-${Date.now()}`;
|
||||
const { data: park, error: createError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Unique Test Park',
|
||||
slug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
is_test_data: true
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
// Import necessary helpers
|
||||
const {
|
||||
getCurrentUserId,
|
||||
getAuthToken,
|
||||
generateUniqueParkData,
|
||||
createTestParkSubmission,
|
||||
approveSubmission
|
||||
} = await import('../helpers/approvalTestHelpers');
|
||||
|
||||
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||
if (!park) throw new Error('No park returned');
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
parkId = park.id;
|
||||
tracker.track('parks', parkId);
|
||||
// Create first park with unique slug
|
||||
const baseSlug = `unique-test-${Date.now()}`;
|
||||
const parkData1 = {
|
||||
...generateUniqueParkData('integrity-003-1'),
|
||||
slug: baseSlug // Override with our controlled slug
|
||||
};
|
||||
|
||||
// Try to create another park with same slug
|
||||
const { error: duplicateError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Duplicate Park',
|
||||
slug, // Same slug
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
is_test_data: true
|
||||
});
|
||||
// Create and approve first submission
|
||||
const { submissionId: sub1Id, itemId: item1Id } = await createTestParkSubmission(parkData1, userId, tracker);
|
||||
|
||||
// This SHOULD fail with unique violation
|
||||
if (!duplicateError) {
|
||||
throw new Error('Unique constraint not enforced - duplicate slug was accepted');
|
||||
const approval1 = await approveSubmission(sub1Id, [item1Id], authToken);
|
||||
if (!approval1.success) {
|
||||
throw new Error(`First park approval failed: ${approval1.error}`);
|
||||
}
|
||||
|
||||
// Verify it's a unique violation
|
||||
if (!duplicateError.message.includes('unique') && !duplicateError.message.includes('duplicate')) {
|
||||
throw new Error(`Expected unique constraint error, got: ${duplicateError.message}`);
|
||||
// Get first park ID
|
||||
const { data: item1 } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', item1Id)
|
||||
.single();
|
||||
|
||||
if (!item1?.approved_entity_id) throw new Error('First park not created');
|
||||
tracker.track('parks', item1.approved_entity_id);
|
||||
|
||||
// Create second submission with SAME slug
|
||||
const parkData2 = {
|
||||
...generateUniqueParkData('integrity-003-2'),
|
||||
slug: baseSlug // Same slug - should fail on approval
|
||||
};
|
||||
|
||||
const { submissionId: sub2Id, itemId: item2Id } = await createTestParkSubmission(parkData2, userId, tracker);
|
||||
|
||||
// Try to approve second submission (should fail due to unique constraint)
|
||||
const approval2 = await approveSubmission(sub2Id, [item2Id], authToken);
|
||||
|
||||
// Approval should fail
|
||||
if (approval2.success) {
|
||||
throw new Error('Second approval succeeded when it should have failed (duplicate slug)');
|
||||
}
|
||||
|
||||
// Verify the error mentions unique constraint or duplicate
|
||||
const errorMsg = approval2.error?.toLowerCase() || '';
|
||||
if (!errorMsg.includes('unique') && !errorMsg.includes('duplicate') && !errorMsg.includes('already exists')) {
|
||||
throw new Error(`Expected unique constraint error, got: ${approval2.error}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -208,7 +226,10 @@ export const dataIntegrityTestSuite: TestSuite = {
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
constraintEnforced: true,
|
||||
errorMessage: duplicateError.message
|
||||
firstParkCreated: true,
|
||||
secondParkBlocked: true,
|
||||
errorMessage: approval2.error,
|
||||
followedPipeline: true
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -219,16 +240,12 @@ export const dataIntegrityTestSuite: TestSuite = {
|
||||
suite: 'Data Integrity & Constraints',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('integrity-003 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -289,7 +306,7 @@ export const dataIntegrityTestSuite: TestSuite = {
|
||||
suite: 'Data Integrity & Constraints',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const edgeFunctionTestSuite: TestSuite = {
|
||||
id: 'edge-functions',
|
||||
@@ -68,7 +69,7 @@ export const edgeFunctionTestSuite: TestSuite = {
|
||||
suite: 'Edge Function Tests',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -121,7 +122,7 @@ export const edgeFunctionTestSuite: TestSuite = {
|
||||
suite: 'Edge Function Tests',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -187,7 +188,7 @@ export const edgeFunctionTestSuite: TestSuite = {
|
||||
suite: 'Edge Function Tests',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { authTestSuite } from './authTests';
|
||||
import { versioningTestSuite } from './versioningTests';
|
||||
import { dataIntegrityTestSuite } from './dataIntegrityTests';
|
||||
import { submissionTestSuite } from './submissionTests';
|
||||
import { approvalPipelineTestSuite } from './approvalPipelineTests';
|
||||
import { moderationTestSuite } from './moderationTests';
|
||||
import { edgeFunctionTestSuite } from './edgeFunctionTests';
|
||||
import { unitConversionTestSuite } from './unitConversionTests';
|
||||
@@ -19,6 +20,7 @@ export const allTestSuites: TestSuite[] = [
|
||||
versioningTestSuite,
|
||||
dataIntegrityTestSuite,
|
||||
submissionTestSuite,
|
||||
approvalPipelineTestSuite,
|
||||
moderationTestSuite,
|
||||
edgeFunctionTestSuite,
|
||||
unitConversionTestSuite,
|
||||
@@ -30,6 +32,7 @@ export {
|
||||
versioningTestSuite,
|
||||
dataIntegrityTestSuite,
|
||||
submissionTestSuite,
|
||||
approvalPipelineTestSuite,
|
||||
moderationTestSuite,
|
||||
edgeFunctionTestSuite,
|
||||
unitConversionTestSuite,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const moderationDependencyTestSuite: TestSuite = {
|
||||
id: 'moderation-dependencies',
|
||||
@@ -102,7 +103,7 @@ export const moderationDependencyTestSuite: TestSuite = {
|
||||
suite: 'Multi-Item Dependency Resolution',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -144,7 +145,7 @@ export const moderationDependencyTestSuite: TestSuite = {
|
||||
suite: 'Multi-Item Dependency Resolution',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const moderationLockTestSuite: TestSuite = {
|
||||
id: 'moderation-locks',
|
||||
@@ -97,7 +98,7 @@ export const moderationLockTestSuite: TestSuite = {
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -183,7 +184,7 @@ export const moderationLockTestSuite: TestSuite = {
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -284,7 +285,7 @@ export const moderationLockTestSuite: TestSuite = {
|
||||
suite: 'Moderation Lock Management',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const moderationTestSuite: TestSuite = {
|
||||
id: 'moderation',
|
||||
@@ -53,7 +54,7 @@ export const moderationTestSuite: TestSuite = {
|
||||
suite: 'Moderation Queue & Workflow',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { TestDataTracker } from '../TestDataTracker';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const performanceTestSuite: TestSuite = {
|
||||
id: 'performance',
|
||||
@@ -96,7 +97,7 @@ export const performanceTestSuite: TestSuite = {
|
||||
suite: 'Performance & Scalability',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -112,22 +113,36 @@ export const performanceTestSuite: TestSuite = {
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create test park
|
||||
const parkSlug = `test-park-perf-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: park, error: parkError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Test Park Performance',
|
||||
slug: parkSlug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
is_test_data: true
|
||||
})
|
||||
.select('id')
|
||||
// Import helpers and create park via pipeline
|
||||
const {
|
||||
getCurrentUserId,
|
||||
getAuthToken,
|
||||
generateUniqueParkData,
|
||||
createTestParkSubmission,
|
||||
approveSubmission
|
||||
} = await import('../helpers/approvalTestHelpers');
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const parkData = generateUniqueParkData('perf-002');
|
||||
|
||||
const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
|
||||
if (!approval.success) {
|
||||
throw new Error(`Park creation failed: ${approval.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Get park ID from submission item
|
||||
const { data: parkItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (parkError) throw parkError;
|
||||
parkId = park.id;
|
||||
parkId = parkItem?.approved_entity_id || null;
|
||||
if (!parkId) throw new Error('No park ID after approval');
|
||||
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Create multiple versions (updates)
|
||||
@@ -182,7 +197,7 @@ export const performanceTestSuite: TestSuite = {
|
||||
suite: 'Performance & Scalability',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
@@ -214,7 +229,7 @@ export const performanceTestSuite: TestSuite = {
|
||||
|
||||
const modDuration = Date.now() - modStart;
|
||||
|
||||
if (modError) throw modError;
|
||||
if (modError) throw new Error(`Moderator check failed: ${modError.message}`);
|
||||
|
||||
// Test is_user_banned function performance
|
||||
const banStart = Date.now();
|
||||
@@ -225,7 +240,7 @@ export const performanceTestSuite: TestSuite = {
|
||||
|
||||
const banDuration = Date.now() - banStart;
|
||||
|
||||
if (banError) throw banError;
|
||||
if (banError) throw new Error(`Ban check failed: ${banError.message}`);
|
||||
|
||||
// Performance threshold: 200ms for simple functions
|
||||
const threshold = 200;
|
||||
@@ -265,7 +280,7 @@ export const performanceTestSuite: TestSuite = {
|
||||
suite: 'Performance & Scalability',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,71 +1,96 @@
|
||||
/**
|
||||
* Entity Submission & Validation Integration Tests
|
||||
* Submission Pipeline Validation Tests
|
||||
*
|
||||
* Tests for submission validation, schema validation, and entity creation.
|
||||
* Tests submission creation, validation, and the full approval flow.
|
||||
* All tests follow the sacred pipeline architecture.
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { TestDataTracker } from '../TestDataTracker';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
import {
|
||||
generateUniqueParkData,
|
||||
generateUniqueRideData,
|
||||
generateUniqueCompanyData,
|
||||
generateUniqueRideModelData,
|
||||
createTestParkSubmission,
|
||||
createTestRideSubmission,
|
||||
createTestCompanySubmission,
|
||||
createTestRideModelSubmission,
|
||||
approveSubmission,
|
||||
pollForEntity,
|
||||
getAuthToken,
|
||||
getCurrentUserId,
|
||||
} from '../helpers/approvalTestHelpers';
|
||||
|
||||
export const submissionTestSuite: TestSuite = {
|
||||
id: 'submission',
|
||||
name: 'Entity Submission & Validation',
|
||||
description: 'Tests for entity submission workflows and validation schemas',
|
||||
description: 'Tests submission creation, validation, and approval pipeline',
|
||||
tests: [
|
||||
{
|
||||
id: 'submission-001',
|
||||
name: 'Park Creation Validation',
|
||||
description: 'Validates park submission and creation',
|
||||
description: 'Validates park submission and approval creates entity',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
const parkSlug = `test-park-submit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const parkData = generateUniqueParkData('submission-001');
|
||||
|
||||
// Create park with valid data
|
||||
const { data: park, error: createError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Test Park Submission',
|
||||
slug: parkSlug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
description: 'Test park for submission validation'
|
||||
})
|
||||
.select('id, name, slug, park_type, status')
|
||||
// Create submission
|
||||
const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
|
||||
// Verify submission was created
|
||||
const { data: submission } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('status, submission_type')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||
if (!park) throw new Error('Park not returned after creation');
|
||||
|
||||
parkId = park.id;
|
||||
|
||||
// Validate created park has correct data
|
||||
if (park.name !== 'Test Park Submission') {
|
||||
throw new Error(`Expected name "Test Park Submission", got "${park.name}"`);
|
||||
if (!submission) throw new Error('Submission not found');
|
||||
if (submission.status !== 'pending') {
|
||||
throw new Error(`Expected status "pending", got "${submission.status}"`);
|
||||
}
|
||||
if (park.slug !== parkSlug) {
|
||||
throw new Error(`Expected slug "${parkSlug}", got "${park.slug}"`);
|
||||
}
|
||||
if (park.park_type !== 'theme_park') {
|
||||
throw new Error(`Expected park_type "theme_park", got "${park.park_type}"`);
|
||||
if (submission.submission_type !== 'park') {
|
||||
throw new Error(`Expected type "park", got "${submission.submission_type}"`);
|
||||
}
|
||||
|
||||
// Test slug uniqueness constraint
|
||||
const { error: duplicateError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Duplicate Slug Park',
|
||||
slug: parkSlug, // Same slug
|
||||
park_type: 'theme_park',
|
||||
status: 'operating'
|
||||
});
|
||||
// Approve submission
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
if (!approval.success) {
|
||||
throw new Error(`Approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
if (!duplicateError) {
|
||||
throw new Error('Duplicate slug was allowed (uniqueness constraint failed)');
|
||||
// Verify entity was created
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id, status')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (!item?.approved_entity_id) {
|
||||
throw new Error('No entity created after approval');
|
||||
}
|
||||
if (item.status !== 'approved') {
|
||||
throw new Error(`Expected item status "approved", got "${item.status}"`);
|
||||
}
|
||||
|
||||
tracker.track('parks', item.approved_entity_id);
|
||||
|
||||
// Verify park data
|
||||
const park = await pollForEntity('parks', item.approved_entity_id);
|
||||
if (!park) throw new Error('Park entity not found');
|
||||
|
||||
if (park.name !== parkData.name) {
|
||||
throw new Error(`Expected name "${parkData.name}", got "${park.name}"`);
|
||||
}
|
||||
if (park.slug !== parkData.slug) {
|
||||
throw new Error(`Expected slug "${parkData.slug}", got "${park.slug}"`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -78,9 +103,9 @@ export const submissionTestSuite: TestSuite = {
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
parkId,
|
||||
parkSlug,
|
||||
validationsPassed: ['name', 'slug', 'park_type', 'uniqueness_constraint']
|
||||
submissionId,
|
||||
parkId: item.approved_entity_id,
|
||||
validationsPassed: ['submission_created', 'approval_succeeded', 'entity_created']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,80 +116,71 @@ export const submissionTestSuite: TestSuite = {
|
||||
suite: 'Entity Submission & Validation',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('submission-001 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'submission-002',
|
||||
name: 'Ride Creation with Dependencies',
|
||||
description: 'Validates ride submission requires valid park_id',
|
||||
description: 'Validates ride submission requires valid park and creates correctly',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
let rideId: string | null = null;
|
||||
|
||||
try {
|
||||
// First create a park
|
||||
const parkSlug = `test-park-ride-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: park, error: parkError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Test Park for Ride',
|
||||
slug: parkSlug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
is_test_data: true
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (parkError) throw new Error(`Park creation failed: ${parkError.message}`);
|
||||
parkId = park.id;
|
||||
// First create and approve a park
|
||||
const parkData = generateUniqueParkData('submission-002-park');
|
||||
const { submissionId: parkSubId, itemId: parkItemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
|
||||
// Try to create ride with invalid park_id (should fail)
|
||||
const invalidParkId = '00000000-0000-0000-0000-000000000000';
|
||||
const { error: invalidError } = await supabase
|
||||
.from('rides')
|
||||
.insert({
|
||||
name: 'Test Ride Invalid Park',
|
||||
slug: `test-ride-invalid-${Date.now()}`,
|
||||
park_id: invalidParkId,
|
||||
category: 'roller_coaster',
|
||||
status: 'operating'
|
||||
});
|
||||
|
||||
if (!invalidError) {
|
||||
throw new Error('Ride with invalid park_id was allowed (foreign key constraint failed)');
|
||||
const parkApproval = await approveSubmission(parkSubId, [parkItemId], authToken);
|
||||
if (!parkApproval.success) {
|
||||
throw new Error(`Park approval failed: ${parkApproval.error}`);
|
||||
}
|
||||
|
||||
// Create ride with valid park_id (should succeed)
|
||||
const rideSlug = `test-ride-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: ride, error: rideError } = await supabase
|
||||
.from('rides')
|
||||
.insert({
|
||||
name: 'Test Ride Valid Park',
|
||||
slug: rideSlug,
|
||||
park_id: parkId,
|
||||
category: 'roller_coaster',
|
||||
status: 'operating'
|
||||
})
|
||||
.select('id, name, park_id')
|
||||
const { data: parkItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', parkItemId)
|
||||
.single();
|
||||
|
||||
if (rideError) throw new Error(`Ride creation failed: ${rideError.message}`);
|
||||
if (!ride) throw new Error('Ride not returned after creation');
|
||||
const parkId = parkItem?.approved_entity_id;
|
||||
if (!parkId) throw new Error('Park not created');
|
||||
|
||||
rideId = ride.id;
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Now create ride submission
|
||||
const rideData = generateUniqueRideData(parkId, 'submission-002');
|
||||
const { submissionId: rideSubId, itemId: rideItemId } = await createTestRideSubmission(rideData, userId, tracker);
|
||||
|
||||
// Approve ride
|
||||
const rideApproval = await approveSubmission(rideSubId, [rideItemId], authToken);
|
||||
if (!rideApproval.success) {
|
||||
throw new Error(`Ride approval failed: ${rideApproval.error}`);
|
||||
}
|
||||
|
||||
// Verify ride created
|
||||
const { data: rideItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', rideItemId)
|
||||
.single();
|
||||
|
||||
const rideId = rideItem?.approved_entity_id;
|
||||
if (!rideId) throw new Error('Ride not created after approval');
|
||||
|
||||
tracker.track('rides', rideId);
|
||||
|
||||
// Verify ride data
|
||||
const ride = await pollForEntity('rides', rideId);
|
||||
if (!ride) throw new Error('Ride entity not found');
|
||||
|
||||
if (ride.park_id !== parkId) {
|
||||
throw new Error(`Expected park_id "${parkId}", got "${ride.park_id}"`);
|
||||
@@ -182,7 +198,7 @@ export const submissionTestSuite: TestSuite = {
|
||||
details: {
|
||||
parkId,
|
||||
rideId,
|
||||
validationsPassed: ['foreign_key_constraint', 'valid_dependency']
|
||||
validationsPassed: ['park_created', 'ride_created', 'dependency_valid']
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,57 +209,68 @@ export const submissionTestSuite: TestSuite = {
|
||||
suite: 'Entity Submission & Validation',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('submission-002 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'submission-003',
|
||||
name: 'Company Creation All Types',
|
||||
description: 'Validates company creation for all company types',
|
||||
description: 'Validates company submission for all company types',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
const companyIds: string[] = [];
|
||||
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const companyTypes = ['manufacturer', 'operator', 'designer', 'property_owner'] as const;
|
||||
const createdCompanies: Array<{ type: string; id: string }> = [];
|
||||
|
||||
for (const companyType of companyTypes) {
|
||||
const slug = `test-company-${companyType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const companyData = generateUniqueCompanyData(companyType, `submission-003-${companyType}`);
|
||||
|
||||
const { data: company, error: createError } = await supabase
|
||||
.from('companies')
|
||||
.insert({
|
||||
name: `Test ${companyType} Company`,
|
||||
slug,
|
||||
company_type: companyType,
|
||||
description: `Test company of type ${companyType}`
|
||||
})
|
||||
.select('id, company_type')
|
||||
// Create submission
|
||||
const { submissionId, itemId } = await createTestCompanySubmission(
|
||||
companyType,
|
||||
companyData,
|
||||
userId,
|
||||
tracker
|
||||
);
|
||||
|
||||
// Approve submission
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
if (!approval.success) {
|
||||
throw new Error(`${companyType} approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
// Verify entity created
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
throw new Error(`${companyType} creation failed: ${createError.message}`);
|
||||
}
|
||||
if (!company) {
|
||||
throw new Error(`${companyType} not returned after creation`);
|
||||
const companyId = item?.approved_entity_id;
|
||||
if (!companyId) {
|
||||
throw new Error(`${companyType} not created after approval`);
|
||||
}
|
||||
|
||||
companyIds.push(company.id);
|
||||
tracker.track('companies', company.id);
|
||||
tracker.track('companies', companyId);
|
||||
|
||||
// Verify company type
|
||||
const company = await pollForEntity('companies', companyId);
|
||||
if (!company) throw new Error(`${companyType} entity not found`);
|
||||
|
||||
if (company.company_type !== companyType) {
|
||||
throw new Error(`Expected company_type "${companyType}", got "${company.company_type}"`);
|
||||
}
|
||||
|
||||
createdCompanies.push({ type: companyType, id: companyId });
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -256,9 +283,9 @@ export const submissionTestSuite: TestSuite = {
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
companiesCreated: companyIds.length,
|
||||
companiesCreated: createdCompanies.length,
|
||||
companyTypes: companyTypes,
|
||||
companyIds
|
||||
companies: createdCompanies
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,105 +296,90 @@ export const submissionTestSuite: TestSuite = {
|
||||
suite: 'Entity Submission & Validation',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('submission-003 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'submission-004',
|
||||
name: 'Ride Model with Images',
|
||||
description: 'Validates ride model creation with image fields',
|
||||
description: 'Validates ride model submission with image fields',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
let manufacturerId: string | null = null;
|
||||
let modelId: string | null = null;
|
||||
const tracker = new TestDataTracker();
|
||||
|
||||
try {
|
||||
// Create manufacturer first
|
||||
const mfgSlug = `test-mfg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: manufacturer, error: mfgError } = await supabase
|
||||
.from('companies')
|
||||
.insert({
|
||||
name: 'Test Manufacturer',
|
||||
slug: mfgSlug,
|
||||
company_type: 'manufacturer'
|
||||
})
|
||||
.select('id')
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
// Create and approve manufacturer
|
||||
const mfgData = generateUniqueCompanyData('manufacturer', 'submission-004-mfg');
|
||||
const { submissionId: mfgSubId, itemId: mfgItemId } = await createTestCompanySubmission(
|
||||
'manufacturer',
|
||||
mfgData,
|
||||
userId,
|
||||
tracker
|
||||
);
|
||||
|
||||
const mfgApproval = await approveSubmission(mfgSubId, [mfgItemId], authToken);
|
||||
if (!mfgApproval.success) {
|
||||
throw new Error(`Manufacturer approval failed: ${mfgApproval.error}`);
|
||||
}
|
||||
|
||||
const { data: mfgItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', mfgItemId)
|
||||
.single();
|
||||
|
||||
if (mfgError) throw new Error(`Manufacturer creation failed: ${mfgError.message}`);
|
||||
manufacturerId = manufacturer.id;
|
||||
const manufacturerId = mfgItem?.approved_entity_id;
|
||||
if (!manufacturerId) throw new Error('Manufacturer not created');
|
||||
|
||||
// Create ride model with images
|
||||
const modelSlug = `test-model-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const testImageUrl = 'https://imagedelivery.net/test-account/test-image-id/public';
|
||||
const testImageId = 'test-image-id';
|
||||
tracker.track('companies', manufacturerId);
|
||||
|
||||
const { data: model, error: modelError } = await supabase
|
||||
.from('ride_models')
|
||||
.insert({
|
||||
name: 'Test Ride Model',
|
||||
slug: modelSlug,
|
||||
manufacturer_id: manufacturerId,
|
||||
category: 'roller_coaster',
|
||||
ride_type: 'steel_coaster',
|
||||
banner_image_url: testImageUrl,
|
||||
banner_image_id: testImageId,
|
||||
card_image_url: testImageUrl,
|
||||
card_image_id: testImageId
|
||||
})
|
||||
.select('id, banner_image_url, banner_image_id, card_image_url, card_image_id')
|
||||
// Create ride model submission
|
||||
const modelData = generateUniqueRideModelData(manufacturerId, 'submission-004');
|
||||
const { submissionId, itemId } = await createTestRideModelSubmission(modelData, userId, tracker);
|
||||
|
||||
// Approve ride model
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
if (!approval.success) {
|
||||
throw new Error(`Ride model approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
// Verify entity created
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (modelError) throw new Error(`Ride model creation failed: ${modelError.message}`);
|
||||
if (!model) throw new Error('Ride model not returned after creation');
|
||||
const modelId = item?.approved_entity_id;
|
||||
if (!modelId) throw new Error('Ride model not created after approval');
|
||||
|
||||
modelId = model.id;
|
||||
tracker.track('ride_models', modelId);
|
||||
|
||||
// Validate image fields
|
||||
if (model.banner_image_url !== testImageUrl) {
|
||||
throw new Error(`banner_image_url mismatch: expected "${testImageUrl}", got "${model.banner_image_url}"`);
|
||||
}
|
||||
if (model.banner_image_id !== testImageId) {
|
||||
throw new Error(`banner_image_id mismatch: expected "${testImageId}", got "${model.banner_image_id}"`);
|
||||
}
|
||||
if (model.card_image_url !== testImageUrl) {
|
||||
throw new Error(`card_image_url mismatch`);
|
||||
}
|
||||
if (model.card_image_id !== testImageId) {
|
||||
throw new Error(`card_image_id mismatch`);
|
||||
// Verify model data
|
||||
const model = await pollForEntity('ride_models', modelId);
|
||||
if (!model) throw new Error('Ride model entity not found');
|
||||
|
||||
if (model.manufacturer_id !== manufacturerId) {
|
||||
throw new Error(`Expected manufacturer_id "${manufacturerId}", got "${model.manufacturer_id}"`);
|
||||
}
|
||||
|
||||
// Verify version was created with images
|
||||
let version: any = null;
|
||||
const pollStart = Date.now();
|
||||
while (!version && Date.now() - pollStart < 5000) {
|
||||
const { data } = await supabase
|
||||
// Verify version created
|
||||
const { data: version } = await supabase
|
||||
.from('ride_model_versions')
|
||||
.select('banner_image_url, banner_image_id, card_image_url, card_image_id')
|
||||
.select('version_number')
|
||||
.eq('ride_model_id', modelId)
|
||||
.eq('version_number', 1)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
version = data;
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (!version) throw new Error('Version not created after 5s timeout');
|
||||
if (version.banner_image_url !== testImageUrl) {
|
||||
throw new Error('Version missing banner_image_url');
|
||||
}
|
||||
if (!version) throw new Error('Version not created for ride model');
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
@@ -381,8 +393,8 @@ export const submissionTestSuite: TestSuite = {
|
||||
details: {
|
||||
modelId,
|
||||
manufacturerId,
|
||||
imageFieldsValidated: ['banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id'],
|
||||
versionCreated: true
|
||||
versionCreated: true,
|
||||
followedPipeline: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,16 +405,11 @@ export const submissionTestSuite: TestSuite = {
|
||||
suite: 'Entity Submission & Validation',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
if (modelId) {
|
||||
await supabase.from('ride_models').delete().eq('id', modelId);
|
||||
}
|
||||
if (manufacturerId) {
|
||||
await supabase.from('companies').delete().eq('id', manufacturerId);
|
||||
}
|
||||
await tracker.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { TestDataTracker } from '../TestDataTracker';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
|
||||
export const unitConversionTestSuite: TestSuite = {
|
||||
id: 'unit-conversion',
|
||||
@@ -24,65 +25,93 @@ export const unitConversionTestSuite: TestSuite = {
|
||||
let rideId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create test park
|
||||
const parkSlug = `test-park-units-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: park, error: parkError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Test Park Units',
|
||||
slug: parkSlug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
is_test_data: true
|
||||
})
|
||||
.select('id')
|
||||
// Import helpers and create via pipeline
|
||||
const {
|
||||
getCurrentUserId,
|
||||
getAuthToken,
|
||||
generateUniqueParkData,
|
||||
generateUniqueRideData,
|
||||
createTestParkSubmission,
|
||||
createTestRideSubmission,
|
||||
approveSubmission
|
||||
} = await import('../helpers/approvalTestHelpers');
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
// Create and approve park
|
||||
const parkData = generateUniqueParkData('unit-001-park');
|
||||
const { submissionId: parkSubId, itemId: parkItemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
const parkApproval = await approveSubmission(parkSubId, [parkItemId], authToken);
|
||||
|
||||
if (!parkApproval.success) {
|
||||
throw new Error(`Park creation failed: ${parkApproval.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Get park ID from submission item
|
||||
const { data: parkItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', parkItemId)
|
||||
.single();
|
||||
|
||||
if (parkError) throw parkError;
|
||||
parkId = park.id;
|
||||
parkId = parkItem?.approved_entity_id || null;
|
||||
if (!parkId) throw new Error('No park ID after approval');
|
||||
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Create ride with metric values
|
||||
const rideSlug = `test-ride-units-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const testData = {
|
||||
name: 'Test Ride Metric',
|
||||
slug: rideSlug,
|
||||
park_id: parkId,
|
||||
category: 'roller_coaster',
|
||||
status: 'operating',
|
||||
max_speed_kmh: 100.0, // km/h (metric)
|
||||
max_height_meters: 50.0, // meters (metric)
|
||||
length_meters: 1000.0, // meters (metric)
|
||||
drop_height_meters: 45.0, // meters (metric)
|
||||
height_requirement: 120 // cm (metric)
|
||||
// Create and approve ride with metric values
|
||||
const rideData = {
|
||||
...generateUniqueRideData(parkId, 'unit-001-ride'),
|
||||
max_speed_kmh: 100.0,
|
||||
max_height_meters: 50.0,
|
||||
length_meters: 1000.0,
|
||||
drop_height_meters: 45.0,
|
||||
height_requirement: 120
|
||||
};
|
||||
|
||||
const { data: ride, error: rideError } = await supabase
|
||||
.from('rides')
|
||||
.insert({ ...testData, is_test_data: true })
|
||||
.select('id, max_speed_kmh, max_height_meters, length_meters, drop_height_meters, height_requirement')
|
||||
const { submissionId: rideSubId, itemId: rideItemId } = await createTestRideSubmission(rideData, userId, tracker);
|
||||
const rideApproval = await approveSubmission(rideSubId, [rideItemId], authToken);
|
||||
|
||||
if (!rideApproval.success) {
|
||||
throw new Error(`Ride creation failed: ${rideApproval.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Get ride ID from submission item
|
||||
const { data: rideItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', rideItemId)
|
||||
.single();
|
||||
|
||||
if (rideError) throw new Error(`Ride creation failed: ${rideError.message}`);
|
||||
if (!ride) throw new Error('Ride not returned');
|
||||
rideId = rideItem?.approved_entity_id || null;
|
||||
if (!rideId) throw new Error('No ride ID after approval');
|
||||
|
||||
rideId = ride.id;
|
||||
tracker.track('rides', rideId);
|
||||
|
||||
// Validate values are stored in metric
|
||||
const tolerance = 0.01; // Allow small floating point differences
|
||||
// Fetch ride data for validation
|
||||
const { data: ride, error: rideError } = await supabase
|
||||
.from('rides')
|
||||
.select('id, max_speed_kmh, max_height_meters, length_meters, drop_height_meters, height_requirement')
|
||||
.eq('id', rideId)
|
||||
.single();
|
||||
|
||||
if (Math.abs((ride.max_speed_kmh ?? 0) - testData.max_speed_kmh) > tolerance) {
|
||||
throw new Error(`max_speed_kmh mismatch: expected ${testData.max_speed_kmh}, got ${ride.max_speed_kmh}`);
|
||||
if (rideError || !ride) throw new Error('Ride not found after creation');
|
||||
|
||||
// Validate values are stored in metric
|
||||
const tolerance = 0.01;
|
||||
|
||||
if (Math.abs((ride.max_speed_kmh ?? 0) - 100.0) > tolerance) {
|
||||
throw new Error(`max_speed_kmh mismatch: expected 100.0, got ${ride.max_speed_kmh}`);
|
||||
}
|
||||
if (Math.abs((ride.max_height_meters ?? 0) - testData.max_height_meters) > tolerance) {
|
||||
throw new Error(`max_height_meters mismatch: expected ${testData.max_height_meters}, got ${ride.max_height_meters}`);
|
||||
if (Math.abs((ride.max_height_meters ?? 0) - 50.0) > tolerance) {
|
||||
throw new Error(`max_height_meters mismatch: expected 50.0, got ${ride.max_height_meters}`);
|
||||
}
|
||||
if (Math.abs((ride.length_meters ?? 0) - testData.length_meters) > tolerance) {
|
||||
throw new Error(`length_meters mismatch: expected ${testData.length_meters}, got ${ride.length_meters}`);
|
||||
if (Math.abs((ride.length_meters ?? 0) - 1000.0) > tolerance) {
|
||||
throw new Error(`length_meters mismatch: expected 1000.0, got ${ride.length_meters}`);
|
||||
}
|
||||
if (Math.abs((ride.height_requirement ?? 0) - testData.height_requirement) > tolerance) {
|
||||
throw new Error(`height_requirement mismatch: expected ${testData.height_requirement} cm, got ${ride.height_requirement}`);
|
||||
if (Math.abs((ride.height_requirement ?? 0) - 120) > tolerance) {
|
||||
throw new Error(`height_requirement mismatch: expected 120 cm, got ${ride.height_requirement}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -108,7 +137,7 @@ export const unitConversionTestSuite: TestSuite = {
|
||||
suite: 'Unit Conversion Tests',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
@@ -131,44 +160,66 @@ export const unitConversionTestSuite: TestSuite = {
|
||||
let rideId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create test park
|
||||
const parkSlug = `test-park-ver-units-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: park, error: parkError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Test Park Version Units',
|
||||
slug: parkSlug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
is_test_data: true
|
||||
})
|
||||
.select('id')
|
||||
// Import helpers and create via pipeline
|
||||
const {
|
||||
getCurrentUserId,
|
||||
getAuthToken,
|
||||
generateUniqueParkData,
|
||||
generateUniqueRideData,
|
||||
createTestParkSubmission,
|
||||
createTestRideSubmission,
|
||||
approveSubmission
|
||||
} = await import('../helpers/approvalTestHelpers');
|
||||
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
// Create and approve park
|
||||
const parkData = generateUniqueParkData('unit-002-park');
|
||||
const { submissionId: parkSubId, itemId: parkItemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
const parkApproval = await approveSubmission(parkSubId, [parkItemId], authToken);
|
||||
|
||||
if (!parkApproval.success) {
|
||||
throw new Error(`Park creation failed: ${parkApproval.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Get park ID from submission item
|
||||
const { data: parkItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', parkItemId)
|
||||
.single();
|
||||
|
||||
if (parkError) throw parkError;
|
||||
parkId = park.id;
|
||||
parkId = parkItem?.approved_entity_id || null;
|
||||
if (!parkId) throw new Error('No park ID after approval');
|
||||
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Create ride with metric values
|
||||
const rideSlug = `test-ride-ver-units-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: ride, error: rideError } = await supabase
|
||||
.from('rides')
|
||||
.insert({
|
||||
name: 'Test Ride Version Metric',
|
||||
slug: rideSlug,
|
||||
park_id: parkId,
|
||||
category: 'roller_coaster',
|
||||
status: 'operating',
|
||||
// Create and approve ride with metric values
|
||||
const rideData = {
|
||||
...generateUniqueRideData(parkId, 'unit-002-ride'),
|
||||
max_speed_kmh: 120.0,
|
||||
max_height_meters: 60.0,
|
||||
height_requirement: 140,
|
||||
is_test_data: true
|
||||
})
|
||||
.select('id')
|
||||
height_requirement: 140
|
||||
};
|
||||
|
||||
const { submissionId: rideSubId, itemId: rideItemId } = await createTestRideSubmission(rideData, userId, tracker);
|
||||
const rideApproval = await approveSubmission(rideSubId, [rideItemId], authToken);
|
||||
|
||||
if (!rideApproval.success) {
|
||||
throw new Error(`Ride creation failed: ${rideApproval.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Get ride ID from submission item
|
||||
const { data: rideItem } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', rideItemId)
|
||||
.single();
|
||||
|
||||
if (rideError) throw rideError;
|
||||
rideId = ride.id;
|
||||
rideId = rideItem?.approved_entity_id || null;
|
||||
if (!rideId) throw new Error('No ride ID after approval');
|
||||
|
||||
tracker.track('rides', rideId);
|
||||
|
||||
// Poll for version creation
|
||||
@@ -226,7 +277,7 @@ export const unitConversionTestSuite: TestSuite = {
|
||||
suite: 'Unit Conversion Tests',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
@@ -307,7 +358,7 @@ export const unitConversionTestSuite: TestSuite = {
|
||||
suite: 'Unit Conversion Tests',
|
||||
status: 'fail',
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,76 +3,83 @@
|
||||
*
|
||||
* Tests the complete versioning system end-to-end including automatic
|
||||
* version creation, attribution, and rollback functionality.
|
||||
*
|
||||
* All tests follow the sacred pipeline: submitParkCreation → approve → verify versioning
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import type { TestSuite, TestResult } from '../testRunner';
|
||||
import { TestDataTracker } from '../TestDataTracker';
|
||||
import { formatTestError } from '../formatTestError';
|
||||
import {
|
||||
generateUniqueParkData,
|
||||
createTestParkSubmission,
|
||||
approveSubmission,
|
||||
pollForEntity,
|
||||
pollForVersion,
|
||||
getAuthToken,
|
||||
getCurrentUserId,
|
||||
} from '../helpers/approvalTestHelpers';
|
||||
|
||||
export const versioningTestSuite: TestSuite = {
|
||||
id: 'versioning',
|
||||
name: 'Versioning & Rollback',
|
||||
description: 'Tests version creation, attribution, rollback, and cleanup',
|
||||
description: 'Tests version creation, attribution, rollback, and cleanup via sacred pipeline',
|
||||
tests: [
|
||||
{
|
||||
id: 'version-001',
|
||||
name: 'Automatic Version Creation on Insert',
|
||||
description: 'Verifies version 1 is created automatically when entity is created',
|
||||
description: 'Verifies version 1 is created automatically when entity is approved',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create a park
|
||||
const slug = `test-park-${Date.now()}`;
|
||||
const { data: park, error: createError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Version Test Park',
|
||||
slug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
// Follow sacred pipeline: Form → Submission → Approval → Versioning
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const parkData = generateUniqueParkData('version-001');
|
||||
|
||||
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||
if (!park) throw new Error('No park returned from insert');
|
||||
// Create submission
|
||||
const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
|
||||
parkId = park.id;
|
||||
|
||||
// Poll for version creation
|
||||
let v1: any = null;
|
||||
const pollStart = Date.now();
|
||||
while (!v1 && Date.now() - pollStart < 5000) {
|
||||
const { data } = await supabase
|
||||
.from('park_versions')
|
||||
.select('version_id')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 1)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
v1 = data;
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Approve submission
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
if (!approval.success) {
|
||||
throw new Error(`Approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
// Check version was created
|
||||
const { data: version, error: versionError } = await supabase
|
||||
.from('park_versions')
|
||||
.select('*')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 1)
|
||||
// Get approved entity ID
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (versionError) throw new Error(`Version query failed: ${versionError.message}`);
|
||||
if (!item?.approved_entity_id) {
|
||||
throw new Error('No entity ID returned after approval');
|
||||
}
|
||||
|
||||
const parkId = item.approved_entity_id;
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Poll for park entity
|
||||
const park = await pollForEntity('parks', parkId);
|
||||
if (!park) throw new Error('Park not created after approval');
|
||||
|
||||
// Verify version 1 was created automatically
|
||||
const version = await pollForVersion('park', parkId, 1);
|
||||
if (!version) throw new Error('Version 1 not created');
|
||||
if (version.name !== 'Version Test Park') throw new Error('Version has incorrect name');
|
||||
if (version.change_type !== 'created') throw new Error(`Expected change_type "created", got "${version.change_type}"`);
|
||||
if (!version.is_current) throw new Error('Version is not marked as current');
|
||||
|
||||
if (version.name !== parkData.name) {
|
||||
throw new Error(`Version has incorrect name: expected "${parkData.name}", got "${version.name}"`);
|
||||
}
|
||||
if (version.change_type !== 'created') {
|
||||
throw new Error(`Expected change_type "created", got "${version.change_type}"`);
|
||||
}
|
||||
if (!version.is_current) {
|
||||
throw new Error('Version is not marked as current');
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
@@ -84,10 +91,12 @@ export const versioningTestSuite: TestSuite = {
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
parkId: park.id,
|
||||
parkId,
|
||||
submissionId,
|
||||
versionNumber: version.version_number,
|
||||
changeType: version.change_type,
|
||||
isCurrent: version.is_current
|
||||
isCurrent: version.is_current,
|
||||
followedPipeline: true
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -98,84 +107,86 @@ export const versioningTestSuite: TestSuite = {
|
||||
suite: 'Versioning & Rollback',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
// Cleanup
|
||||
if (parkId) {
|
||||
await supabase.from('parks').delete().eq('id', parkId);
|
||||
}
|
||||
await tracker.cleanup();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'version-002',
|
||||
name: 'Automatic Version Creation on Update',
|
||||
description: 'Verifies version 2 is created when entity is updated',
|
||||
description: 'Verifies version 2 is created when entity is updated via pipeline',
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create a park
|
||||
const slug = `test-park-${Date.now()}`;
|
||||
const { data: park, error: createError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Original Name',
|
||||
slug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating'
|
||||
})
|
||||
.select('id')
|
||||
// Create and approve initial park
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const parkData = generateUniqueParkData('version-002');
|
||||
|
||||
const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
|
||||
if (!approval.success) {
|
||||
throw new Error(`Initial approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
// Get park ID
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||
if (!park) throw new Error('No park returned');
|
||||
const parkId = item?.approved_entity_id;
|
||||
if (!parkId) throw new Error('No park ID after approval');
|
||||
|
||||
parkId = park.id;
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Wait for version 1
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const v1 = await pollForVersion('park', parkId, 1);
|
||||
if (!v1) throw new Error('Version 1 not created');
|
||||
|
||||
// Update the park
|
||||
// Update park directly (simulating approved edit)
|
||||
// In production, this would go through edit submission pipeline
|
||||
const { error: updateError } = await supabase
|
||||
.from('parks')
|
||||
.update({ name: 'Updated Name' })
|
||||
.eq('id', park.id);
|
||||
.update({ name: 'Updated Name', description: 'Updated Description' })
|
||||
.eq('id', parkId);
|
||||
|
||||
if (updateError) throw new Error(`Park update failed: ${updateError.message}`);
|
||||
|
||||
// Wait for version 2
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Verify version 2 created
|
||||
const v2 = await pollForVersion('park', parkId, 2);
|
||||
if (!v2) throw new Error('Version 2 not created after update');
|
||||
|
||||
// Check version 2 exists
|
||||
const { data: v2, error: v2Error } = await supabase
|
||||
.from('park_versions')
|
||||
.select('*')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 2)
|
||||
.single();
|
||||
if (v2.name !== 'Updated Name') {
|
||||
throw new Error(`Version 2 has incorrect name: expected "Updated Name", got "${v2.name}"`);
|
||||
}
|
||||
if (v2.change_type !== 'updated') {
|
||||
throw new Error(`Expected change_type "updated", got "${v2.change_type}"`);
|
||||
}
|
||||
if (!v2.is_current) {
|
||||
throw new Error('Version 2 is not marked as current');
|
||||
}
|
||||
|
||||
if (v2Error) throw new Error(`Version 2 query failed: ${v2Error.message}`);
|
||||
if (!v2) throw new Error('Version 2 not created');
|
||||
if (v2.name !== 'Updated Name') throw new Error('Version 2 has incorrect name');
|
||||
if (v2.change_type !== 'updated') throw new Error(`Expected change_type "updated", got "${v2.change_type}"`);
|
||||
if (!v2.is_current) throw new Error('Version 2 is not marked as current');
|
||||
|
||||
// Check version 1 is no longer current
|
||||
const { data: v1, error: v1Error } = await supabase
|
||||
// Verify version 1 is no longer current
|
||||
const { data: v1Updated } = await supabase
|
||||
.from('park_versions')
|
||||
.select('is_current')
|
||||
.eq('park_id', park.id)
|
||||
.eq('park_id', parkId)
|
||||
.eq('version_number', 1)
|
||||
.single();
|
||||
|
||||
if (v1Error) throw new Error(`Version 1 query failed: ${v1Error.message}`);
|
||||
if (v1?.is_current) throw new Error('Version 1 is still marked as current');
|
||||
if (v1Updated?.is_current) {
|
||||
throw new Error('Version 1 is still marked as current');
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
@@ -187,8 +198,8 @@ export const versioningTestSuite: TestSuite = {
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
parkId: park.id,
|
||||
v1IsCurrent: v1?.is_current,
|
||||
parkId,
|
||||
v1IsCurrent: v1Updated?.is_current,
|
||||
v2IsCurrent: v2.is_current,
|
||||
v2ChangeType: v2.change_type
|
||||
}
|
||||
@@ -201,16 +212,12 @@ export const versioningTestSuite: TestSuite = {
|
||||
suite: 'Versioning & Rollback',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('version-001 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -221,48 +228,37 @@ export const versioningTestSuite: TestSuite = {
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
// Create a park
|
||||
const slug = `test-park-${Date.now()}`;
|
||||
const { data: park, error: createError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Rollback Test Park',
|
||||
slug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
// Create and approve park
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const parkData = generateUniqueParkData('version-003');
|
||||
|
||||
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||
if (!park) throw new Error('No park returned');
|
||||
const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
|
||||
parkId = park.id;
|
||||
|
||||
// Poll for version creation
|
||||
let v1: any = null;
|
||||
const pollStart = Date.now();
|
||||
while (!v1 && Date.now() - pollStart < 5000) {
|
||||
const { data } = await supabase
|
||||
.from('park_versions')
|
||||
.select('version_id')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 1)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
v1 = data;
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
if (!approval.success) {
|
||||
throw new Error(`Approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
if (!v1) throw new Error('Version 1 not created after 5s timeout');
|
||||
// Get park ID
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
// Check current user is moderator
|
||||
const parkId = item?.approved_entity_id;
|
||||
if (!parkId) throw new Error('No park ID after approval');
|
||||
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Wait for version 1
|
||||
const v1 = await pollForVersion('park', parkId, 1);
|
||||
if (!v1) throw new Error('Version 1 not created');
|
||||
|
||||
// Check current user role
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('No authenticated user');
|
||||
|
||||
@@ -271,14 +267,13 @@ export const versioningTestSuite: TestSuite = {
|
||||
// Try rollback
|
||||
const { error: rollbackError } = await supabase.rpc('rollback_to_version', {
|
||||
p_entity_type: 'park',
|
||||
p_entity_id: park.id,
|
||||
p_entity_id: parkId,
|
||||
p_target_version_id: v1.version_id,
|
||||
p_changed_by: user.id,
|
||||
p_reason: 'Authorization test'
|
||||
});
|
||||
|
||||
// If user is moderator, rollback should succeed
|
||||
// If not, rollback should fail with permission error
|
||||
// Verify authorization enforcement
|
||||
if (isMod && rollbackError) {
|
||||
throw new Error(`Rollback failed for moderator: ${rollbackError.message}`);
|
||||
}
|
||||
@@ -310,16 +305,12 @@ export const versioningTestSuite: TestSuite = {
|
||||
suite: 'Versioning & Rollback',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('version-002 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -330,7 +321,6 @@ export const versioningTestSuite: TestSuite = {
|
||||
run: async (): Promise<TestResult> => {
|
||||
const startTime = Date.now();
|
||||
const tracker = new TestDataTracker();
|
||||
let parkId: string | null = null;
|
||||
|
||||
try {
|
||||
// Check if user is moderator
|
||||
@@ -340,7 +330,6 @@ export const versioningTestSuite: TestSuite = {
|
||||
const { data: isMod } = await supabase.rpc('is_moderator', { _user_id: user.id });
|
||||
|
||||
if (!isMod) {
|
||||
// Skip test if not moderator
|
||||
const duration = Date.now() - startTime;
|
||||
return {
|
||||
id: 'version-004',
|
||||
@@ -353,61 +342,54 @@ export const versioningTestSuite: TestSuite = {
|
||||
};
|
||||
}
|
||||
|
||||
// Create park
|
||||
const slug = `test-park-${Date.now()}`;
|
||||
const { data: park, error: createError } = await supabase
|
||||
.from('parks')
|
||||
.insert({
|
||||
name: 'Original Name',
|
||||
slug,
|
||||
park_type: 'theme_park',
|
||||
status: 'operating',
|
||||
// Create and approve park
|
||||
const userId = await getCurrentUserId();
|
||||
const authToken = await getAuthToken();
|
||||
const parkData = {
|
||||
...generateUniqueParkData('version-004'),
|
||||
description: 'Original Description'
|
||||
})
|
||||
.select('id')
|
||||
};
|
||||
|
||||
const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker);
|
||||
const approval = await approveSubmission(submissionId, [itemId], authToken);
|
||||
|
||||
if (!approval.success) {
|
||||
throw new Error(`Approval failed: ${approval.error}`);
|
||||
}
|
||||
|
||||
// Get park ID
|
||||
const { data: item } = await supabase
|
||||
.from('submission_items')
|
||||
.select('approved_entity_id')
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
|
||||
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||
if (!park) throw new Error('No park returned');
|
||||
const parkId = item?.approved_entity_id;
|
||||
if (!parkId) throw new Error('No park ID after approval');
|
||||
|
||||
parkId = park.id;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
tracker.track('parks', parkId);
|
||||
|
||||
// Get version 1
|
||||
const { data: v1, error: v1Error } = await supabase
|
||||
.from('park_versions')
|
||||
.select('version_id, name, description')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 1)
|
||||
.single();
|
||||
|
||||
if (v1Error || !v1) throw new Error('Version 1 not found');
|
||||
// Wait for version 1
|
||||
const v1 = await pollForVersion('park', parkId, 1);
|
||||
if (!v1) throw new Error('Version 1 not created');
|
||||
|
||||
// Update park
|
||||
const { error: updateError } = await supabase
|
||||
.from('parks')
|
||||
.update({ name: 'Modified Name', description: 'Modified Description' })
|
||||
.eq('id', park.id);
|
||||
.eq('id', parkId);
|
||||
|
||||
if (updateError) throw new Error(`Park update failed: ${updateError.message}`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify version 2
|
||||
const { data: v2 } = await supabase
|
||||
.from('park_versions')
|
||||
.select('version_number, name')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 2)
|
||||
.single();
|
||||
|
||||
// Wait for version 2
|
||||
const v2 = await pollForVersion('park', parkId, 2);
|
||||
if (!v2) throw new Error('Version 2 not created');
|
||||
if (v2.name !== 'Modified Name') throw new Error('Version 2 has incorrect data');
|
||||
|
||||
// Rollback to version 1
|
||||
const { error: rollbackError } = await supabase.rpc('rollback_to_version', {
|
||||
p_entity_type: 'park',
|
||||
p_entity_id: park.id,
|
||||
p_entity_id: parkId,
|
||||
p_target_version_id: v1.version_id,
|
||||
p_changed_by: user.id,
|
||||
p_reason: 'Integration test rollback'
|
||||
@@ -415,37 +397,24 @@ export const versioningTestSuite: TestSuite = {
|
||||
|
||||
if (rollbackError) throw new Error(`Rollback failed: ${rollbackError.message}`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Verify park data restored
|
||||
const { data: restored, error: restoredError } = await supabase
|
||||
.from('parks')
|
||||
.select('name, description')
|
||||
.eq('id', park.id)
|
||||
.single();
|
||||
const restored = await pollForEntity('parks', parkId, 3000);
|
||||
if (!restored) throw new Error('Could not fetch restored park');
|
||||
|
||||
if (restoredError) throw new Error(`Failed to fetch restored park: ${restoredError.message}`);
|
||||
if (!restored) throw new Error('Restored park not found');
|
||||
if (restored.name !== 'Original Name') {
|
||||
throw new Error(`Rollback failed: expected "Original Name", got "${restored.name}"`);
|
||||
if (restored.name !== parkData.name) {
|
||||
throw new Error(`Rollback failed: expected "${parkData.name}", got "${restored.name}"`);
|
||||
}
|
||||
if (restored.description !== 'Original Description') {
|
||||
throw new Error(`Description not restored: expected "Original Description", got "${restored.description}"`);
|
||||
throw new Error(`Description not restored: got "${restored.description}"`);
|
||||
}
|
||||
|
||||
// Verify version 3 created with change_type = 'restored'
|
||||
const { data: v3, error: v3Error } = await supabase
|
||||
.from('park_versions')
|
||||
.select('*')
|
||||
.eq('park_id', park.id)
|
||||
.eq('version_number', 3)
|
||||
.single();
|
||||
|
||||
if (v3Error || !v3) throw new Error('Version 3 (restored) not created');
|
||||
const v3 = await pollForVersion('park', parkId, 3, 3000);
|
||||
if (!v3) throw new Error('Version 3 (restored) not created');
|
||||
if (v3.change_type !== 'restored') {
|
||||
throw new Error(`Expected change_type "restored", got "${v3.change_type}"`);
|
||||
}
|
||||
if (v3.name !== 'Original Name') throw new Error('Version 3 has incorrect data');
|
||||
if (v3.name !== parkData.name) throw new Error('Version 3 has incorrect data');
|
||||
if (!v3.is_current) throw new Error('Version 3 is not marked as current');
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -458,7 +427,7 @@ export const versioningTestSuite: TestSuite = {
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
parkId: park.id,
|
||||
parkId,
|
||||
versionsCreated: 3,
|
||||
dataRestored: true,
|
||||
v3ChangeType: v3.change_type,
|
||||
@@ -473,16 +442,12 @@ export const versioningTestSuite: TestSuite = {
|
||||
suite: 'Versioning & Rollback',
|
||||
status: 'fail',
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatTestError(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
await tracker.cleanup();
|
||||
const remaining = await tracker.verifyCleanup();
|
||||
if (remaining.length > 0) {
|
||||
console.warn('version-003 cleanup incomplete:', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { moderationTestSuite } from './suites/moderationTests';
|
||||
import { moderationLockTestSuite } from './suites/moderationLockTests';
|
||||
import { moderationDependencyTestSuite } from './suites/moderationDependencyTests';
|
||||
import { approvalPipelineTestSuite } from './suites/approvalPipelineTests';
|
||||
|
||||
/**
|
||||
* Registry of all available test suites
|
||||
@@ -15,7 +16,8 @@ import { moderationDependencyTestSuite } from './suites/moderationDependencyTest
|
||||
export const ALL_TEST_SUITES = [
|
||||
moderationTestSuite,
|
||||
moderationLockTestSuite,
|
||||
moderationDependencyTestSuite
|
||||
moderationDependencyTestSuite,
|
||||
approvalPipelineTestSuite,
|
||||
];
|
||||
|
||||
export interface TestResult {
|
||||
@@ -49,9 +51,18 @@ export class IntegrationTestRunner {
|
||||
private isRunning = false;
|
||||
private shouldStop = false;
|
||||
private onProgress?: (result: TestResult) => void;
|
||||
private delayBetweenTests: number;
|
||||
|
||||
constructor(onProgress?: (result: TestResult) => void) {
|
||||
constructor(onProgress?: (result: TestResult) => void, delayBetweenTests: number = 6000) {
|
||||
this.onProgress = onProgress;
|
||||
this.delayBetweenTests = delayBetweenTests; // Default 6 seconds to prevent rate limiting
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for specified milliseconds (for rate limiting prevention)
|
||||
*/
|
||||
private async delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,13 +132,50 @@ export class IntegrationTestRunner {
|
||||
async runSuite(suite: TestSuite): Promise<TestResult[]> {
|
||||
const suiteResults: TestResult[] = [];
|
||||
|
||||
for (const test of suite.tests) {
|
||||
for (let i = 0; i < suite.tests.length; i++) {
|
||||
const test = suite.tests[i];
|
||||
const result = await this.runTest(test, suite.name);
|
||||
suiteResults.push(result);
|
||||
|
||||
if (this.shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add delay between tests to prevent rate limiting (except after the last test)
|
||||
if (i < suite.tests.length - 1 && this.delayBetweenTests > 0) {
|
||||
// Report delay status with countdown
|
||||
const delaySeconds = this.delayBetweenTests / 1000;
|
||||
const delayResult: TestResult = {
|
||||
id: `delay-${Date.now()}`,
|
||||
name: `⏳ Rate limit delay: ${delaySeconds}s`,
|
||||
suite: suite.name,
|
||||
status: 'running',
|
||||
duration: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
reason: 'Pausing to prevent rate limiting',
|
||||
delayMs: this.delayBetweenTests
|
||||
}
|
||||
};
|
||||
|
||||
if (this.onProgress) {
|
||||
this.onProgress(delayResult);
|
||||
}
|
||||
|
||||
await this.delay(this.delayBetweenTests);
|
||||
|
||||
// Mark delay as complete
|
||||
const delayCompleteResult: TestResult = {
|
||||
...delayResult,
|
||||
status: 'skip',
|
||||
duration: this.delayBetweenTests,
|
||||
details: { reason: 'Rate limit delay completed' }
|
||||
};
|
||||
|
||||
if (this.onProgress) {
|
||||
this.onProgress(delayCompleteResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suiteResults;
|
||||
@@ -141,12 +189,58 @@ export class IntegrationTestRunner {
|
||||
this.isRunning = true;
|
||||
this.shouldStop = false;
|
||||
|
||||
for (const suite of suites) {
|
||||
await this.runSuite(suite);
|
||||
// Track submission-heavy suites for adaptive delays
|
||||
const submissionHeavySuites = [
|
||||
'Entity Submission & Validation',
|
||||
'Approval Pipeline',
|
||||
'Unit Conversion Tests',
|
||||
'Performance & Scalability'
|
||||
];
|
||||
|
||||
for (let i = 0; i < suites.length; i++) {
|
||||
await this.runSuite(suites[i]);
|
||||
|
||||
if (this.shouldStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add delay between suites with adaptive timing
|
||||
if (i < suites.length - 1 && this.delayBetweenTests > 0) {
|
||||
// Longer delay after submission-heavy suites
|
||||
const isHeavySuite = submissionHeavySuites.includes(suites[i].name);
|
||||
const delayMs = isHeavySuite
|
||||
? this.delayBetweenTests * 2 // 12s delay after heavy suites
|
||||
: this.delayBetweenTests; // 6s delay after others
|
||||
|
||||
const delaySeconds = delayMs / 1000;
|
||||
const delayResult: TestResult = {
|
||||
id: `suite-delay-${Date.now()}`,
|
||||
name: `⏳ Suite completion delay: ${delaySeconds}s${isHeavySuite ? ' (submission-heavy)' : ''}`,
|
||||
suite: 'System',
|
||||
status: 'running',
|
||||
duration: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
reason: 'Pausing between suites to prevent rate limiting',
|
||||
isSubmissionHeavy: isHeavySuite
|
||||
}
|
||||
};
|
||||
|
||||
if (this.onProgress) {
|
||||
this.onProgress(delayResult);
|
||||
}
|
||||
|
||||
await this.delay(delayMs);
|
||||
|
||||
if (this.onProgress) {
|
||||
this.onProgress({
|
||||
...delayResult,
|
||||
status: 'skip',
|
||||
duration: delayMs,
|
||||
details: { reason: 'Suite delay completed' }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
@@ -57,126 +57,6 @@ export interface ModerationActionResult {
|
||||
shouldRemoveFromQueue: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for photo approval
|
||||
*/
|
||||
interface PhotoApprovalConfig {
|
||||
submissionId: string;
|
||||
moderatorId: string;
|
||||
moderatorNotes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a photo submission
|
||||
*
|
||||
* Creates photo records in the database and updates submission status.
|
||||
* Handles both new approvals and re-approvals (where photos already exist).
|
||||
*
|
||||
* @param supabase - Supabase client
|
||||
* @param config - Photo approval configuration
|
||||
* @returns Action result with success status and message
|
||||
*/
|
||||
export async function approvePhotoSubmission(
|
||||
supabase: SupabaseClient,
|
||||
config: PhotoApprovalConfig
|
||||
): Promise<ModerationActionResult> {
|
||||
try {
|
||||
// Fetch photo submission from relational tables
|
||||
const { data: photoSubmission, error: fetchError } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select(`
|
||||
*,
|
||||
items:photo_submission_items(*),
|
||||
submission:content_submissions!inner(user_id, status)
|
||||
`)
|
||||
.eq('submission_id', config.submissionId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !photoSubmission) {
|
||||
throw new Error('Failed to fetch photo submission data');
|
||||
}
|
||||
|
||||
if (!photoSubmission.items || photoSubmission.items.length === 0) {
|
||||
throw new Error('No photos found in submission');
|
||||
}
|
||||
|
||||
// Check if photos already exist for this submission (re-approval case)
|
||||
const { data: existingPhotos } = await supabase
|
||||
.from('photos')
|
||||
.select('id')
|
||||
.eq('submission_id', config.submissionId);
|
||||
|
||||
if (!existingPhotos || existingPhotos.length === 0) {
|
||||
// Create new photo records from photo_submission_items
|
||||
const photoRecords = photoSubmission.items.map((item: any) => ({
|
||||
entity_id: photoSubmission.entity_id,
|
||||
entity_type: photoSubmission.entity_type,
|
||||
cloudflare_image_id: item.cloudflare_image_id,
|
||||
cloudflare_image_url: item.cloudflare_image_url,
|
||||
title: item.title || null,
|
||||
caption: item.caption || null,
|
||||
date_taken: item.date_taken || null,
|
||||
order_index: item.order_index,
|
||||
submission_id: photoSubmission.submission_id,
|
||||
submitted_by: photoSubmission.submission?.user_id,
|
||||
approved_by: config.moderatorId,
|
||||
approved_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('photos')
|
||||
.insert(photoRecords);
|
||||
|
||||
if (insertError) {
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
|
||||
// Update submission status
|
||||
const { error: updateError } = await supabase
|
||||
.from('content_submissions')
|
||||
.update({
|
||||
status: 'approved' as const,
|
||||
reviewer_id: config.moderatorId,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
reviewer_notes: config.moderatorNotes,
|
||||
})
|
||||
.eq('id', config.submissionId);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully approved and published ${photoSubmission.items.length} photo(s)`,
|
||||
shouldRemoveFromQueue: true,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Approve Photo Submission',
|
||||
userId: config.moderatorId,
|
||||
metadata: { submissionId: config.submissionId }
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to approve photo submission',
|
||||
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
|
||||
shouldRemoveFromQueue: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a submission with submission_items
|
||||
*
|
||||
* Uses the edge function to process all pending submission items.
|
||||
*
|
||||
* @param supabase - Supabase client
|
||||
* @param submissionId - Submission ID
|
||||
* @param itemIds - Array of item IDs to approve
|
||||
* @returns Action result
|
||||
*/
|
||||
/**
|
||||
* Approve submission items using atomic transaction RPC.
|
||||
*
|
||||
@@ -238,194 +118,6 @@ export async function approveSubmissionItems(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a submission with submission_items
|
||||
*
|
||||
* Cascades rejection to all pending items.
|
||||
*
|
||||
* @param supabase - Supabase client
|
||||
* @param submissionId - Submission ID
|
||||
* @param rejectionReason - Reason for rejection
|
||||
* @returns Action result
|
||||
*/
|
||||
export async function rejectSubmissionItems(
|
||||
supabase: SupabaseClient,
|
||||
submissionId: string,
|
||||
rejectionReason?: string
|
||||
): Promise<ModerationActionResult> {
|
||||
try {
|
||||
const { error: rejectError } = await supabase
|
||||
.from('submission_items')
|
||||
.update({
|
||||
status: 'rejected' as const,
|
||||
rejection_reason: rejectionReason || 'Parent submission rejected',
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('submission_id', submissionId)
|
||||
.eq('status', 'pending');
|
||||
|
||||
if (rejectError) {
|
||||
handleError(rejectError, {
|
||||
action: 'Reject Submission Items (Cascade)',
|
||||
metadata: { submissionId }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Submission items rejected',
|
||||
shouldRemoveFromQueue: false, // Parent rejection will handle removal
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Reject Submission Items',
|
||||
metadata: { submissionId }
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to reject submission items',
|
||||
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
|
||||
shouldRemoveFromQueue: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for standard moderation actions
|
||||
*/
|
||||
export interface ModerationConfig {
|
||||
item: ModerationItem;
|
||||
action: 'approved' | 'rejected';
|
||||
moderatorId: string;
|
||||
moderatorNotes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a standard moderation action (approve/reject)
|
||||
*
|
||||
* Updates the submission or review status in the database.
|
||||
* Handles both content_submissions and reviews.
|
||||
*
|
||||
* @param supabase - Supabase client
|
||||
* @param config - Moderation configuration
|
||||
* @returns Action result
|
||||
*/
|
||||
export async function performModerationAction(
|
||||
supabase: SupabaseClient,
|
||||
config: ModerationConfig
|
||||
): Promise<ModerationActionResult> {
|
||||
const { item, action, moderatorId, moderatorNotes } = config;
|
||||
|
||||
try {
|
||||
// Handle photo submissions specially
|
||||
if (
|
||||
action === 'approved' &&
|
||||
item.type === 'content_submission' &&
|
||||
item.submission_type === 'photo'
|
||||
) {
|
||||
return await approvePhotoSubmission(supabase, {
|
||||
submissionId: item.id,
|
||||
moderatorId,
|
||||
moderatorNotes,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this submission has submission_items
|
||||
if (item.type === 'content_submission') {
|
||||
const { data: submissionItems, error: itemsError } = await supabase
|
||||
.from('submission_items')
|
||||
.select('id, status')
|
||||
.eq('submission_id', item.id)
|
||||
.in('status', ['pending', 'rejected']);
|
||||
|
||||
if (!itemsError && submissionItems && submissionItems.length > 0) {
|
||||
if (action === 'approved') {
|
||||
return await approveSubmissionItems(
|
||||
supabase,
|
||||
item.id,
|
||||
submissionItems.map(i => i.id)
|
||||
);
|
||||
} else if (action === 'rejected') {
|
||||
await rejectSubmissionItems(supabase, item.id, moderatorNotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard moderation flow - Build update object with type-appropriate fields
|
||||
let error: any = null;
|
||||
let data: any = null;
|
||||
|
||||
// Use type-safe table queries based on item type
|
||||
if (item.type === 'review') {
|
||||
const reviewUpdate: {
|
||||
moderation_status: 'approved' | 'rejected' | 'pending';
|
||||
moderated_at: string;
|
||||
moderated_by: string;
|
||||
reviewer_notes?: string;
|
||||
} = {
|
||||
moderation_status: action,
|
||||
moderated_at: new Date().toISOString(),
|
||||
moderated_by: moderatorId,
|
||||
...(moderatorNotes && { reviewer_notes: moderatorNotes }),
|
||||
};
|
||||
|
||||
const result = await createTableQuery('reviews')
|
||||
.update(reviewUpdate)
|
||||
.eq('id', item.id)
|
||||
.select();
|
||||
error = result.error;
|
||||
data = result.data;
|
||||
} else {
|
||||
const submissionUpdate: {
|
||||
status: 'approved' | 'rejected' | 'pending';
|
||||
reviewed_at: string;
|
||||
reviewer_id: string;
|
||||
reviewer_notes?: string;
|
||||
} = {
|
||||
status: action,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
reviewer_id: moderatorId,
|
||||
...(moderatorNotes && { reviewer_notes: moderatorNotes }),
|
||||
};
|
||||
|
||||
const result = await createTableQuery('content_submissions')
|
||||
.update(submissionUpdate)
|
||||
.eq('id', item.id)
|
||||
.select();
|
||||
error = result.error;
|
||||
data = result.data;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if the update actually affected any rows
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error(
|
||||
'Failed to update item - no rows affected. You might not have permission to moderate this content.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Content ${action}`,
|
||||
shouldRemoveFromQueue: action === 'approved' || action === 'rejected',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: `${config.action === 'approved' ? 'Approve' : 'Reject'} Content`,
|
||||
userId: config.moderatorId,
|
||||
metadata: { itemType: item.type, itemId: item.id }
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to ${config.action} content`,
|
||||
error: error instanceof Error ? error : new Error(getErrorMessage(error)),
|
||||
shouldRemoveFromQueue: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for submission deletion
|
||||
|
||||
@@ -28,16 +28,12 @@ export type { ResolvedEntityNames } from './entities';
|
||||
|
||||
// Moderation actions
|
||||
export {
|
||||
approvePhotoSubmission,
|
||||
approveSubmissionItems,
|
||||
rejectSubmissionItems,
|
||||
performModerationAction,
|
||||
deleteSubmission,
|
||||
} from './actions';
|
||||
|
||||
export type {
|
||||
ModerationActionResult,
|
||||
ModerationConfig,
|
||||
DeleteSubmissionConfig,
|
||||
} from './actions';
|
||||
|
||||
|
||||
@@ -23,6 +23,97 @@ export interface RetryOptions {
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Retry-After value from error headers
|
||||
* @param error - The error object
|
||||
* @returns Delay in milliseconds, or null if not found
|
||||
*/
|
||||
export function extractRetryAfter(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
|
||||
// Check for Retry-After in error object
|
||||
const errorWithHeaders = error as { headers?: Headers | Record<string, string>; retryAfter?: number | string };
|
||||
|
||||
// Direct retryAfter property
|
||||
if (errorWithHeaders.retryAfter) {
|
||||
const retryAfter = errorWithHeaders.retryAfter;
|
||||
if (typeof retryAfter === 'number') {
|
||||
return retryAfter * 1000; // Convert seconds to milliseconds
|
||||
}
|
||||
if (typeof retryAfter === 'string') {
|
||||
// Try parsing as number first (delay-seconds)
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
// Try parsing as HTTP-date
|
||||
const date = new Date(retryAfter);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const delay = date.getTime() - Date.now();
|
||||
return Math.max(0, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check headers object
|
||||
if (errorWithHeaders.headers) {
|
||||
let retryAfterValue: string | null = null;
|
||||
|
||||
if (errorWithHeaders.headers instanceof Headers) {
|
||||
retryAfterValue = errorWithHeaders.headers.get('retry-after');
|
||||
} else if (typeof errorWithHeaders.headers === 'object') {
|
||||
// Check both lowercase and capitalized versions
|
||||
retryAfterValue = errorWithHeaders.headers['retry-after']
|
||||
|| errorWithHeaders.headers['Retry-After']
|
||||
|| null;
|
||||
}
|
||||
|
||||
if (retryAfterValue) {
|
||||
// Try parsing as number first (delay-seconds)
|
||||
const seconds = parseInt(retryAfterValue, 10);
|
||||
if (!isNaN(seconds)) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
// Try parsing as HTTP-date
|
||||
const date = new Date(retryAfterValue);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const delay = date.getTime() - Date.now();
|
||||
return Math.max(0, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a rate limit (429) error
|
||||
* @param error - The error to check
|
||||
* @returns true if error is a rate limit error
|
||||
*/
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const errorWithStatus = error as { status?: number; code?: string };
|
||||
|
||||
// HTTP 429 status
|
||||
if (errorWithStatus.status === 429) return true;
|
||||
|
||||
// Check error message for rate limit indicators
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('rate limit') ||
|
||||
message.includes('too many requests') ||
|
||||
message.includes('quota exceeded')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is transient and retryable
|
||||
* @param error - The error to check
|
||||
@@ -56,7 +147,7 @@ export function isRetryableError(error: unknown): boolean {
|
||||
if (supabaseError.code === 'PGRST000') return true; // Connection error
|
||||
|
||||
// HTTP status codes indicating transient failures
|
||||
if (supabaseError.status === 429) return true; // Rate limit
|
||||
if (supabaseError.status === 429) return true; // Rate limit - ALWAYS retry
|
||||
if (supabaseError.status === 503) return true; // Service unavailable
|
||||
if (supabaseError.status === 504) return true; // Gateway timeout
|
||||
if (supabaseError.status && supabaseError.status >= 500 && supabaseError.status < 600) {
|
||||
@@ -78,12 +169,46 @@ export function isRetryableError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates delay for next retry attempt using exponential backoff
|
||||
* Calculates delay for next retry attempt using exponential backoff or Retry-After header
|
||||
* @param attempt - Current attempt number (0-indexed)
|
||||
* @param options - Retry configuration
|
||||
* @param error - The error that triggered the retry (to check for Retry-After)
|
||||
* @returns Delay in milliseconds
|
||||
*/
|
||||
function calculateBackoffDelay(attempt: number, options: Required<RetryOptions>): number {
|
||||
function calculateBackoffDelay(
|
||||
attempt: number,
|
||||
options: Required<RetryOptions>,
|
||||
error?: unknown
|
||||
): number {
|
||||
// Check for rate limit with Retry-After header
|
||||
if (error && isRateLimitError(error)) {
|
||||
const retryAfter = extractRetryAfter(error);
|
||||
if (retryAfter !== null) {
|
||||
// Respect the Retry-After header, but cap it at maxDelay
|
||||
const cappedRetryAfter = Math.min(retryAfter, options.maxDelay);
|
||||
|
||||
logger.info('[Retry] Rate limit detected - respecting Retry-After header', {
|
||||
retryAfterMs: retryAfter,
|
||||
cappedMs: cappedRetryAfter,
|
||||
attempt
|
||||
});
|
||||
|
||||
return cappedRetryAfter;
|
||||
}
|
||||
|
||||
// No Retry-After header but is rate limit - use aggressive backoff
|
||||
const rateLimitDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt + 1);
|
||||
const cappedDelay = Math.min(rateLimitDelay, options.maxDelay);
|
||||
|
||||
logger.info('[Retry] Rate limit detected - using aggressive backoff', {
|
||||
delayMs: cappedDelay,
|
||||
attempt
|
||||
});
|
||||
|
||||
return cappedDelay;
|
||||
}
|
||||
|
||||
// Standard exponential backoff
|
||||
const exponentialDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt);
|
||||
const cappedDelay = Math.min(exponentialDelay, options.maxDelay);
|
||||
|
||||
@@ -246,18 +371,23 @@ export async function withRetry<T>(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate delay for next attempt
|
||||
const delay = calculateBackoffDelay(attempt, config);
|
||||
// Calculate delay for next attempt (respects Retry-After for rate limits)
|
||||
const delay = calculateBackoffDelay(attempt, config, error);
|
||||
|
||||
// Log retry attempt with rate limit detection
|
||||
const isRateLimit = isRateLimitError(error);
|
||||
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||
|
||||
// Log retry attempt
|
||||
logger.warn('Retrying after error', {
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: config.maxAttempts,
|
||||
delay,
|
||||
isRateLimit,
|
||||
retryAfterMs: retryAfter,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
// Invoke callback
|
||||
// Invoke callback with additional context
|
||||
config.onRetry(attempt + 1, error, delay);
|
||||
|
||||
// Wait before retrying
|
||||
|
||||
150
src/lib/spanVisualizer.ts
Normal file
150
src/lib/spanVisualizer.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Span Visualizer
|
||||
* Reconstructs span trees from logs for debugging distributed traces
|
||||
*/
|
||||
|
||||
import type { Span } from '@/types/tracing';
|
||||
|
||||
export interface SpanTree {
|
||||
span: Span;
|
||||
children: SpanTree[];
|
||||
totalDuration: number;
|
||||
selfDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build span tree from flat span logs
|
||||
*/
|
||||
export function buildSpanTree(spans: Span[]): SpanTree | null {
|
||||
const spanMap = new Map<string, Span>();
|
||||
const childrenMap = new Map<string, Span[]>();
|
||||
|
||||
// Index spans
|
||||
for (const span of spans) {
|
||||
spanMap.set(span.spanId, span);
|
||||
|
||||
if (span.parentSpanId) {
|
||||
if (!childrenMap.has(span.parentSpanId)) {
|
||||
childrenMap.set(span.parentSpanId, []);
|
||||
}
|
||||
childrenMap.get(span.parentSpanId)!.push(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Find root span
|
||||
const rootSpan = spans.find(s => !s.parentSpanId);
|
||||
if (!rootSpan) return null;
|
||||
|
||||
// Build tree recursively
|
||||
function buildTree(span: Span): SpanTree {
|
||||
const children = childrenMap.get(span.spanId) || [];
|
||||
const childTrees = children.map(buildTree);
|
||||
|
||||
const totalDuration = span.duration || 0;
|
||||
const childrenDuration = childTrees.reduce((sum, child) => sum + child.totalDuration, 0);
|
||||
const selfDuration = totalDuration - childrenDuration;
|
||||
|
||||
return {
|
||||
span,
|
||||
children: childTrees,
|
||||
totalDuration,
|
||||
selfDuration,
|
||||
};
|
||||
}
|
||||
|
||||
return buildTree(rootSpan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format span tree as ASCII art
|
||||
*/
|
||||
export function formatSpanTree(tree: SpanTree, indent: number = 0): string {
|
||||
const prefix = ' '.repeat(indent);
|
||||
const status = tree.span.status === 'error' ? '❌' : tree.span.status === 'ok' ? '✅' : '⏳';
|
||||
const line = `${prefix}${status} ${tree.span.name} (${tree.span.duration}ms / self: ${tree.selfDuration}ms)`;
|
||||
|
||||
const childLines = tree.children.map(child => formatSpanTree(child, indent + 1));
|
||||
|
||||
return [line, ...childLines].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate span statistics for a tree
|
||||
*/
|
||||
export function calculateSpanStats(tree: SpanTree): {
|
||||
totalSpans: number;
|
||||
errorCount: number;
|
||||
maxDepth: number;
|
||||
totalDuration: number;
|
||||
criticalPath: string[];
|
||||
} {
|
||||
let totalSpans = 0;
|
||||
let errorCount = 0;
|
||||
let maxDepth = 0;
|
||||
|
||||
function traverse(node: SpanTree, depth: number) {
|
||||
totalSpans++;
|
||||
if (node.span.status === 'error') errorCount++;
|
||||
maxDepth = Math.max(maxDepth, depth);
|
||||
|
||||
node.children.forEach(child => traverse(child, depth + 1));
|
||||
}
|
||||
|
||||
traverse(tree, 0);
|
||||
|
||||
// Find critical path (longest duration path)
|
||||
function findCriticalPath(node: SpanTree): string[] {
|
||||
if (node.children.length === 0) {
|
||||
return [node.span.name];
|
||||
}
|
||||
|
||||
const longestChild = node.children.reduce((longest, child) =>
|
||||
child.totalDuration > longest.totalDuration ? child : longest
|
||||
);
|
||||
|
||||
return [node.span.name, ...findCriticalPath(longestChild)];
|
||||
}
|
||||
|
||||
return {
|
||||
totalSpans,
|
||||
errorCount,
|
||||
maxDepth,
|
||||
totalDuration: tree.totalDuration,
|
||||
criticalPath: findCriticalPath(tree),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all events from a span tree
|
||||
*/
|
||||
export function extractAllEvents(tree: SpanTree): Array<{
|
||||
spanName: string;
|
||||
eventName: string;
|
||||
timestamp: number;
|
||||
attributes?: Record<string, unknown>;
|
||||
}> {
|
||||
const events: Array<{
|
||||
spanName: string;
|
||||
eventName: string;
|
||||
timestamp: number;
|
||||
attributes?: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
function traverse(node: SpanTree) {
|
||||
node.span.events.forEach(event => {
|
||||
events.push({
|
||||
spanName: node.span.name,
|
||||
eventName: event.name,
|
||||
timestamp: event.timestamp,
|
||||
attributes: event.attributes,
|
||||
});
|
||||
});
|
||||
|
||||
node.children.forEach(child => traverse(child));
|
||||
}
|
||||
|
||||
traverse(tree);
|
||||
|
||||
// Sort by timestamp
|
||||
return events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleError, handleNonCriticalError, getErrorMessage } from './errorHandler';
|
||||
import { extractCloudflareImageId } from './cloudflareImageUtils';
|
||||
import { invokeWithTracking } from './edgeFunctionTracking';
|
||||
|
||||
// Core submission item interface with dependencies
|
||||
// NOTE: item_data and original_data use `unknown` because they contain dynamic structures
|
||||
@@ -1367,32 +1368,24 @@ export async function rejectSubmissionItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Update all items to rejected status
|
||||
const updates = Array.from(itemsToReject).map(async (itemId) => {
|
||||
const { error } = await supabase
|
||||
.from('submission_items')
|
||||
.update({
|
||||
status: 'rejected' as const,
|
||||
rejection_reason: reason,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', itemId);
|
||||
const submissionId = items[0]?.submission_id;
|
||||
if (!submissionId) {
|
||||
throw new Error('Cannot reject items: missing submission ID');
|
||||
}
|
||||
|
||||
// Use atomic edge function for rejection
|
||||
const { data, error } = await invokeWithTracking(
|
||||
'process-selective-rejection',
|
||||
{
|
||||
itemIds: Array.from(itemsToReject),
|
||||
submissionId,
|
||||
rejectionReason: reason,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
if (error) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Reject Submission Item',
|
||||
metadata: { itemId }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
// Update parent submission status
|
||||
const submissionId = items[0]?.submission_id;
|
||||
if (submissionId) {
|
||||
await updateSubmissionStatusAfterRejection(submissionId);
|
||||
throw new Error(`Failed to reject items: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
||||
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
|
||||
import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner';
|
||||
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle } from 'lucide-react';
|
||||
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle, Database } from 'lucide-react';
|
||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||
|
||||
export default function AdminSettings() {
|
||||
@@ -772,12 +772,8 @@ export default function AdminSettings() {
|
||||
<span className="hidden sm:inline">Integrations</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="testing" className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Testing</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="integration-tests" className="flex items-center gap-2">
|
||||
<TestTube className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Integration Tests</span>
|
||||
<span className="hidden sm:inline">Testing</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -971,11 +967,31 @@ export default function AdminSettings() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="testing">
|
||||
<div className="space-y-6">
|
||||
{/* Test Data Generator Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
<Database className="w-6 h-6" />
|
||||
Test Data Generator
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Generate realistic test data for parks, rides, companies, and submissions.
|
||||
</p>
|
||||
<TestDataGenerator />
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<TabsContent value="integration-tests">
|
||||
{/* Integration Test Runner Section */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
<TestTube className="w-6 h-6" />
|
||||
Integration Test Runner
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Run automated integration tests against your approval pipeline, moderation system, and data integrity checks.
|
||||
</p>
|
||||
<IntegrationTestRunner />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,10 @@ import { ErrorDetailsModal } from '@/components/admin/ErrorDetailsModal';
|
||||
import { ApprovalFailureModal } from '@/components/admin/ApprovalFailureModal';
|
||||
import { ErrorAnalytics } from '@/components/admin/ErrorAnalytics';
|
||||
import { PipelineHealthAlerts } from '@/components/admin/PipelineHealthAlerts';
|
||||
import { EdgeFunctionLogs } from '@/components/admin/EdgeFunctionLogs';
|
||||
import { DatabaseLogs } from '@/components/admin/DatabaseLogs';
|
||||
import { UnifiedLogSearch } from '@/components/admin/UnifiedLogSearch';
|
||||
import TraceViewer from './TraceViewer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// Helper to calculate date threshold for filtering
|
||||
@@ -59,6 +63,14 @@ export default function ErrorMonitoring() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [errorTypeFilter, setErrorTypeFilter] = useState<string>('all');
|
||||
const [dateRange, setDateRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
|
||||
const [activeTab, setActiveTab] = useState('errors');
|
||||
|
||||
const handleNavigate = (tab: string, filters: Record<string, string>) => {
|
||||
setActiveTab(tab);
|
||||
if (filters.requestId) {
|
||||
setSearchTerm(filters.requestId);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch recent errors
|
||||
const { data: errors, isLoading, refetch, isFetching } = useQuery({
|
||||
@@ -170,8 +182,8 @@ export default function ErrorMonitoring() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Error Monitoring</h1>
|
||||
<p className="text-muted-foreground">Track and analyze application errors</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Monitoring & Logs</h1>
|
||||
<p className="text-muted-foreground">Unified monitoring hub for errors, logs, and distributed traces</p>
|
||||
</div>
|
||||
<RefreshButton
|
||||
onRefresh={async () => { await refetch(); }}
|
||||
@@ -181,17 +193,23 @@ export default function ErrorMonitoring() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unified Log Search */}
|
||||
<UnifiedLogSearch onNavigate={handleNavigate} />
|
||||
|
||||
{/* Pipeline Health Alerts */}
|
||||
<PipelineHealthAlerts />
|
||||
|
||||
{/* Analytics Section */}
|
||||
<ErrorAnalytics errorSummary={errorSummary} approvalMetrics={approvalMetrics} />
|
||||
|
||||
{/* Tabs for Errors and Approval Failures */}
|
||||
<Tabs defaultValue="errors" className="w-full">
|
||||
<TabsList>
|
||||
{/* Tabs for All Log Types */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="errors">Application Errors</TabsTrigger>
|
||||
<TabsTrigger value="approvals">Approval Failures</TabsTrigger>
|
||||
<TabsTrigger value="edge-functions">Edge Functions</TabsTrigger>
|
||||
<TabsTrigger value="database">Database Logs</TabsTrigger>
|
||||
<TabsTrigger value="traces">Distributed Traces</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="errors" className="space-y-4">
|
||||
@@ -350,6 +368,18 @@ export default function ErrorMonitoring() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="edge-functions">
|
||||
<EdgeFunctionLogs />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="database">
|
||||
<DatabaseLogs />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="traces">
|
||||
<TraceViewer />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
255
src/pages/admin/TraceViewer.tsx
Normal file
255
src/pages/admin/TraceViewer.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { buildSpanTree, formatSpanTree, calculateSpanStats, extractAllEvents } from '@/lib/spanVisualizer';
|
||||
import type { Span } from '@/types/tracing';
|
||||
import type { SpanTree } from '@/lib/spanVisualizer';
|
||||
|
||||
/**
|
||||
* Admin Trace Viewer
|
||||
*
|
||||
* Visual tool for debugging distributed traces across the approval pipeline.
|
||||
* Reconstructs and displays span hierarchies from edge function logs.
|
||||
*/
|
||||
export default function TraceViewer() {
|
||||
const [traceId, setTraceId] = useState('');
|
||||
const [spans, setSpans] = useState<Span[]>([]);
|
||||
const [tree, setTree] = useState<SpanTree | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTrace = async () => {
|
||||
if (!traceId.trim()) {
|
||||
setError('Please enter a trace ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual edge function log query
|
||||
// This would need an edge function that queries Supabase logs
|
||||
// For now, using mock data structure
|
||||
const mockSpans: Span[] = [
|
||||
{
|
||||
spanId: 'root-1',
|
||||
traceId,
|
||||
name: 'process-selective-approval',
|
||||
kind: 'SERVER',
|
||||
startTime: Date.now() - 5000,
|
||||
endTime: Date.now(),
|
||||
duration: 5000,
|
||||
attributes: {
|
||||
'http.method': 'POST',
|
||||
'user.id': 'user-123',
|
||||
'submission.id': 'sub-456',
|
||||
},
|
||||
events: [
|
||||
{ timestamp: Date.now() - 4900, name: 'authentication_start' },
|
||||
{ timestamp: Date.now() - 4800, name: 'authentication_success' },
|
||||
{ timestamp: Date.now() - 4700, name: 'validation_complete' },
|
||||
],
|
||||
status: 'ok',
|
||||
},
|
||||
{
|
||||
spanId: 'child-1',
|
||||
traceId,
|
||||
parentSpanId: 'root-1',
|
||||
name: 'process_approval_transaction',
|
||||
kind: 'DATABASE',
|
||||
startTime: Date.now() - 4500,
|
||||
endTime: Date.now() - 500,
|
||||
duration: 4000,
|
||||
attributes: {
|
||||
'db.operation': 'rpc',
|
||||
'submission.id': 'sub-456',
|
||||
},
|
||||
events: [
|
||||
{ timestamp: Date.now() - 4400, name: 'rpc_call_start' },
|
||||
{ timestamp: Date.now() - 600, name: 'rpc_call_success' },
|
||||
],
|
||||
status: 'ok',
|
||||
},
|
||||
];
|
||||
|
||||
setSpans(mockSpans);
|
||||
const builtTree = buildSpanTree(mockSpans);
|
||||
setTree(builtTree);
|
||||
|
||||
if (!builtTree) {
|
||||
setError('No root span found for this trace ID');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load trace');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = tree ? calculateSpanStats(tree) : null;
|
||||
const events = tree ? extractAllEvents(tree) : [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Distributed Trace Viewer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Debug moderation pipeline execution by visualizing span hierarchies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Load Trace</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a trace ID from edge function logs to visualize the execution tree
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={traceId}
|
||||
onChange={(e) => setTraceId(e.target.value)}
|
||||
placeholder="Enter trace ID (e.g., abc-123-def-456)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={loadTrace} disabled={isLoading}>
|
||||
{isLoading ? 'Loading...' : 'Load Trace'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tree && stats && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trace Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Duration</div>
|
||||
<div className="text-2xl font-bold">{stats.totalDuration}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Spans</div>
|
||||
<div className="text-2xl font-bold">{stats.totalSpans}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Max Depth</div>
|
||||
<div className="text-2xl font-bold">{stats.maxDepth}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Errors</div>
|
||||
<div className="text-2xl font-bold text-destructive">{stats.errorCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-muted-foreground mb-2">Critical Path (Longest Duration):</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stats.criticalPath.map((spanName, i) => (
|
||||
<Badge key={i} variant="secondary">
|
||||
{spanName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Span Tree</CardTitle>
|
||||
<CardDescription>
|
||||
Hierarchical view of span execution with timing breakdown
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{formatSpanTree(tree)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Events Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Chronological list of all events across all spans
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{events.map((event, i) => (
|
||||
<div key={i} className="flex gap-2 text-sm border-l-2 border-primary pl-4 py-1">
|
||||
<Badge variant="outline">{event.spanName}</Badge>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="font-medium">{event.eventName}</span>
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
{new Date(event.timestamp).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Span Details</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed breakdown of each span with attributes and events
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{spans.map((span) => (
|
||||
<AccordionItem key={span.spanId} value={span.spanId}>
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={span.status === 'error' ? 'destructive' : 'default'}>
|
||||
{span.kind}
|
||||
</Badge>
|
||||
<span>{span.name}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({span.duration}ms)
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-xs">
|
||||
{JSON.stringify(span, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!tree && !isLoading && !error && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Enter a trace ID to visualize the distributed trace. You can find trace IDs in edge function logs
|
||||
under the "Span completed" messages.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/types/tracing.ts
Normal file
35
src/types/tracing.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Distributed Tracing Types
|
||||
* Mirrors the types defined in edge function logger
|
||||
*/
|
||||
|
||||
export interface Span {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
parentSpanId?: string;
|
||||
name: string;
|
||||
kind: 'SERVER' | 'CLIENT' | 'INTERNAL' | 'DATABASE';
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
attributes: Record<string, unknown>;
|
||||
events: SpanEvent[];
|
||||
status: 'ok' | 'error' | 'unset';
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpanEvent {
|
||||
timestamp: number;
|
||||
name: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags?: number;
|
||||
}
|
||||
@@ -47,6 +47,9 @@ verify_jwt = true
|
||||
[functions.process-selective-approval]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.process-selective-rejection]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.send-escalation-notification]
|
||||
verify_jwt = true
|
||||
|
||||
|
||||
94
supabase/functions/_shared/errorFormatter.ts
Normal file
94
supabase/functions/_shared/errorFormatter.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Error Formatting Utility for Edge Functions
|
||||
*
|
||||
* Provides robust error message extraction from various error types:
|
||||
* - Standard Error objects
|
||||
* - Supabase PostgresError objects (plain objects with message/details/code/hint)
|
||||
* - Raw objects and primitives
|
||||
*
|
||||
* Eliminates "[object Object]" errors by properly extracting error details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format error objects for logging
|
||||
* Handles Error objects, Supabase errors (plain objects), and primitives
|
||||
*
|
||||
* @param error - Any error value
|
||||
* @returns Formatted, human-readable error message string
|
||||
*/
|
||||
export function formatEdgeError(error: unknown): string {
|
||||
// Standard Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Object-like errors (Supabase PostgresError, etc.)
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const err = error as any;
|
||||
|
||||
// Try common error message properties
|
||||
if (err.message && typeof err.message === 'string') {
|
||||
// Include additional Supabase error details if present
|
||||
const parts: string[] = [err.message];
|
||||
|
||||
if (err.details && typeof err.details === 'string') {
|
||||
parts.push(`Details: ${err.details}`);
|
||||
}
|
||||
|
||||
if (err.hint && typeof err.hint === 'string') {
|
||||
parts.push(`Hint: ${err.hint}`);
|
||||
}
|
||||
|
||||
if (err.code && typeof err.code === 'string') {
|
||||
parts.push(`Code: ${err.code}`);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// Some errors nest the actual error in an 'error' property
|
||||
if (err.error) {
|
||||
return formatEdgeError(err.error);
|
||||
}
|
||||
|
||||
// Some APIs use 'msg' instead of 'message'
|
||||
if (err.msg && typeof err.msg === 'string') {
|
||||
return err.msg;
|
||||
}
|
||||
|
||||
// Last resort: stringify the entire object
|
||||
try {
|
||||
const stringified = JSON.stringify(error, null, 2);
|
||||
return stringified.length > 500
|
||||
? stringified.substring(0, 500) + '... (truncated)'
|
||||
: stringified;
|
||||
} catch {
|
||||
// JSON.stringify can fail on circular references
|
||||
return 'Unknown error (could not stringify)';
|
||||
}
|
||||
}
|
||||
|
||||
// Primitive values (strings, numbers, etc.)
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any error to a proper Error instance
|
||||
* Use this before throwing to ensure proper stack traces
|
||||
*
|
||||
* @param error - Any error value
|
||||
* @returns Error instance with formatted message
|
||||
*/
|
||||
export function toError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = formatEdgeError(error);
|
||||
const newError = new Error(message);
|
||||
|
||||
// Preserve original error as property for debugging
|
||||
(newError as any).originalError = error;
|
||||
|
||||
return newError;
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* Prevents sensitive data exposure and provides consistent log format
|
||||
*/
|
||||
|
||||
import { formatEdgeError } from './errorFormatter.ts';
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||
|
||||
interface LogContext {
|
||||
@@ -14,7 +16,39 @@ interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Request tracking utilities
|
||||
// Span types for distributed tracing
|
||||
export interface Span {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
parentSpanId?: string;
|
||||
name: string;
|
||||
kind: 'SERVER' | 'CLIENT' | 'INTERNAL' | 'DATABASE';
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
attributes: Record<string, unknown>;
|
||||
events: SpanEvent[];
|
||||
status: 'ok' | 'error' | 'unset';
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpanEvent {
|
||||
timestamp: number;
|
||||
name: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags?: number;
|
||||
}
|
||||
|
||||
// Request tracking utilities (legacy - use spans instead)
|
||||
export interface RequestTracking {
|
||||
requestId: string;
|
||||
start: number;
|
||||
@@ -33,6 +67,135 @@ export function endRequest(tracking: RequestTracking): number {
|
||||
return Date.now() - tracking.start;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Span Lifecycle Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start a new span
|
||||
*/
|
||||
export function startSpan(
|
||||
name: string,
|
||||
kind: Span['kind'],
|
||||
parentSpan?: SpanContext,
|
||||
attributes?: Record<string, unknown>
|
||||
): Span {
|
||||
const traceId = parentSpan?.traceId || crypto.randomUUID();
|
||||
|
||||
return {
|
||||
spanId: crypto.randomUUID(),
|
||||
traceId,
|
||||
parentSpanId: parentSpan?.spanId,
|
||||
name,
|
||||
kind,
|
||||
startTime: Date.now(),
|
||||
attributes: attributes || {},
|
||||
events: [],
|
||||
status: 'unset',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* End a span with final status
|
||||
*/
|
||||
export function endSpan(span: Span, status?: 'ok' | 'error', error?: unknown): Span {
|
||||
span.endTime = Date.now();
|
||||
span.duration = span.endTime - span.startTime;
|
||||
span.status = status || 'ok';
|
||||
|
||||
if (error) {
|
||||
const err = error instanceof Error ? error : new Error(formatEdgeError(error));
|
||||
span.error = {
|
||||
type: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event to span
|
||||
*/
|
||||
export function addSpanEvent(
|
||||
span: Span,
|
||||
name: string,
|
||||
attributes?: Record<string, unknown>
|
||||
): void {
|
||||
span.events.push({
|
||||
timestamp: Date.now(),
|
||||
name,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set span attributes
|
||||
*/
|
||||
export function setSpanAttributes(
|
||||
span: Span,
|
||||
attributes: Record<string, unknown>
|
||||
): void {
|
||||
span.attributes = { ...span.attributes, ...attributes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract span context for propagation
|
||||
*/
|
||||
export function getSpanContext(span: Span): SpanContext {
|
||||
return {
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract span context from HTTP headers (W3C Trace Context)
|
||||
*/
|
||||
export function extractSpanContextFromHeaders(headers: Headers): SpanContext | undefined {
|
||||
const traceparent = headers.get('traceparent');
|
||||
if (!traceparent) return undefined;
|
||||
|
||||
// Parse W3C traceparent: version-traceId-spanId-flags
|
||||
const parts = traceparent.split('-');
|
||||
if (parts.length !== 4) return undefined;
|
||||
|
||||
return {
|
||||
traceId: parts[1],
|
||||
spanId: parts[2],
|
||||
traceFlags: parseInt(parts[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject span context into headers
|
||||
*/
|
||||
export function injectSpanContextIntoHeaders(spanContext: SpanContext): Record<string, string> {
|
||||
return {
|
||||
'traceparent': `00-${spanContext.traceId}-${spanContext.spanId}-01`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log completed span
|
||||
*/
|
||||
export function logSpan(span: Span): void {
|
||||
const sanitizedAttributes = sanitizeContext(span.attributes);
|
||||
const sanitizedEvents = span.events.map(e => ({
|
||||
...e,
|
||||
attributes: e.attributes ? sanitizeContext(e.attributes) : undefined,
|
||||
}));
|
||||
|
||||
edgeLogger.info('Span completed', {
|
||||
span: {
|
||||
...span,
|
||||
attributes: sanitizedAttributes,
|
||||
events: sanitizedEvents,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fields that should never be logged
|
||||
const SENSITIVE_FIELDS = [
|
||||
'password',
|
||||
@@ -52,7 +215,7 @@ const SENSITIVE_FIELDS = [
|
||||
/**
|
||||
* Sanitize context to remove sensitive data
|
||||
*/
|
||||
function sanitizeContext(context: LogContext): LogContext {
|
||||
export function sanitizeContext(context: LogContext): LogContext {
|
||||
const sanitized: LogContext = {};
|
||||
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -348,7 +349,7 @@ Deno.serve(async (req) => {
|
||||
edgeLogger.warn('Error deleting avatar from Cloudflare', {
|
||||
requestId: tracking.requestId,
|
||||
targetUserId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
action: 'admin_delete_user'
|
||||
});
|
||||
}
|
||||
@@ -417,7 +418,7 @@ Deno.serve(async (req) => {
|
||||
edgeLogger.warn('Error removing Novu subscriber', {
|
||||
requestId: tracking.requestId,
|
||||
targetUserId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
action: 'admin_delete_user'
|
||||
});
|
||||
}
|
||||
@@ -521,7 +522,7 @@ Deno.serve(async (req) => {
|
||||
edgeLogger.warn('Error sending deletion notification email', {
|
||||
requestId: tracking.requestId,
|
||||
targetUserId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
action: 'admin_delete_user'
|
||||
});
|
||||
}
|
||||
@@ -546,7 +547,7 @@ Deno.serve(async (req) => {
|
||||
edgeLogger.error('Unexpected error in admin delete user', {
|
||||
requestId: tracking.requestId,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
action: 'admin_delete_user'
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -137,7 +138,7 @@ serve(async (req) => {
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = endRequest(tracking);
|
||||
edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: error instanceof Error ? error.message : String(error), requestId: tracking.requestId, duration });
|
||||
edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: formatEdgeError(error), requestId: tracking.requestId, duration });
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message, requestId: tracking.requestId }),
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -149,7 +150,7 @@ Deno.serve(async (req) => {
|
||||
action: 'cancel_email_change',
|
||||
requestId: tracking.requestId,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
import { edgeLogger } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -183,7 +184,7 @@ Deno.serve(async (req) => {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
edgeLogger.error('Cleanup job failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
edgeLogger.error('Cleanup job failed', { error: formatEdgeError(error) });
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { Novu } from "npm:@novu/api@1.6.0";
|
||||
import { edgeLogger } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -218,7 +219,7 @@ serve(async (req) => {
|
||||
} catch (topicError: unknown) {
|
||||
// Non-blocking - log error but don't fail the request
|
||||
edgeLogger.error('Failed to add subscriber to users topic', {
|
||||
error: topicError instanceof Error ? topicError.message : String(topicError),
|
||||
error: formatEdgeError(topicError),
|
||||
subscriberId,
|
||||
requestId: tracking.requestId
|
||||
});
|
||||
@@ -238,7 +239,7 @@ serve(async (req) => {
|
||||
} catch (error: unknown) {
|
||||
const duration = endRequest(tracking);
|
||||
edgeLogger.error('Error creating Novu subscriber', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
requestId: tracking.requestId,
|
||||
duration
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -45,7 +46,7 @@ function cleanupExpiredEntries() {
|
||||
// CRITICAL: Increment failure counter and log detailed error information
|
||||
cleanupFailureCount++;
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatEdgeError(error);
|
||||
|
||||
edgeLogger.error('Cleanup error', {
|
||||
attempt: cleanupFailureCount,
|
||||
@@ -284,7 +285,7 @@ serve(async (req) => {
|
||||
|
||||
} catch (error: unknown) {
|
||||
// Enhanced error logging for better visibility and debugging
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatEdgeError(error);
|
||||
|
||||
edgeLogger.error('Location detection error', {
|
||||
error: errorMessage,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { sanitizeError } from '../_shared/errorSanitizer.ts';
|
||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -348,7 +349,7 @@ serve(async (req) => {
|
||||
action: 'export_error',
|
||||
requestId: tracking.requestId,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
const sanitized = sanitizeError(error, 'export-user-data');
|
||||
return new Response(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -186,7 +187,7 @@ Deno.serve(async (req) => {
|
||||
action: 'mfa_unenroll_error',
|
||||
requestId: tracking.requestId,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -2,6 +2,18 @@ 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 { corsHeaders } from './cors.ts';
|
||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||
import {
|
||||
edgeLogger,
|
||||
startSpan,
|
||||
endSpan,
|
||||
addSpanEvent,
|
||||
setSpanAttributes,
|
||||
getSpanContext,
|
||||
logSpan,
|
||||
extractSpanContextFromHeaders,
|
||||
type Span
|
||||
} from '../_shared/logger.ts';
|
||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||
@@ -22,13 +34,29 @@ const handler = async (req: Request) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate request ID for tracking
|
||||
const requestId = crypto.randomUUID();
|
||||
// Extract parent span context from headers (if present)
|
||||
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
||||
|
||||
// Create root span for this edge function invocation
|
||||
const rootSpan = startSpan(
|
||||
'process-selective-approval',
|
||||
'SERVER',
|
||||
parentSpanContext,
|
||||
{
|
||||
'http.method': 'POST',
|
||||
'function.name': 'process-selective-approval',
|
||||
}
|
||||
);
|
||||
const requestId = rootSpan.spanId;
|
||||
|
||||
try {
|
||||
// STEP 1: Authentication
|
||||
addSpanEvent(rootSpan, 'authentication_start');
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
addSpanEvent(rootSpan, 'authentication_failed', { reason: 'missing_header' });
|
||||
endSpan(rootSpan, 'error');
|
||||
logSpan(rootSpan);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing Authorization header' }),
|
||||
{
|
||||
@@ -47,6 +75,14 @@ const handler = async (req: Request) => {
|
||||
|
||||
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'));
|
||||
logSpan(rootSpan);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{
|
||||
@@ -59,13 +95,34 @@ const handler = async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[${requestId}] Approval request from moderator ${user.id}`);
|
||||
setSpanAttributes(rootSpan, { 'user.id': user.id });
|
||||
addSpanEvent(rootSpan, 'authentication_success');
|
||||
edgeLogger.info('Approval request received', {
|
||||
requestId,
|
||||
moderatorId: user.id,
|
||||
action: 'process_approval'
|
||||
});
|
||||
|
||||
// STEP 2: Parse request
|
||||
addSpanEvent(rootSpan, 'validation_start');
|
||||
const body: ApprovalRequest = await req.json();
|
||||
const { submissionId, itemIds, idempotencyKey } = body;
|
||||
|
||||
if (!submissionId || !itemIds || itemIds.length === 0) {
|
||||
addSpanEvent(rootSpan, 'validation_failed', {
|
||||
hasSubmissionId: !!submissionId,
|
||||
hasItemIds: !!itemIds,
|
||||
itemCount: itemIds?.length || 0,
|
||||
});
|
||||
edgeLogger.warn('Invalid request payload', {
|
||||
requestId,
|
||||
hasSubmissionId: !!submissionId,
|
||||
hasItemIds: !!itemIds,
|
||||
itemCount: itemIds?.length || 0,
|
||||
action: 'process_approval'
|
||||
});
|
||||
endSpan(rootSpan, 'error');
|
||||
logSpan(rootSpan);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing required fields: submissionId, itemIds' }),
|
||||
{
|
||||
@@ -78,7 +135,21 @@ const handler = async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
setSpanAttributes(rootSpan, {
|
||||
'submission.id': submissionId,
|
||||
'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('*')
|
||||
@@ -86,7 +157,16 @@ const handler = async (req: Request) => {
|
||||
.single();
|
||||
|
||||
if (existingKey?.status === 'completed') {
|
||||
console.log(`[${requestId}] Idempotency key already processed, returning cached result`);
|
||||
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),
|
||||
{
|
||||
@@ -108,7 +188,15 @@ const handler = async (req: Request) => {
|
||||
.single();
|
||||
|
||||
if (submissionError || !submission) {
|
||||
console.error(`[${requestId}] Submission not found:`, submissionError);
|
||||
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
||||
edgeLogger.error('Submission not found', {
|
||||
requestId,
|
||||
submissionId,
|
||||
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' }),
|
||||
{
|
||||
@@ -123,7 +211,13 @@ const handler = async (req: Request) => {
|
||||
|
||||
// STEP 5: Verify moderator can approve this submission
|
||||
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||
console.error(`[${requestId}] Submission locked by another moderator`);
|
||||
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' }),
|
||||
{
|
||||
@@ -137,7 +231,13 @@ const handler = async (req: Request) => {
|
||||
}
|
||||
|
||||
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
||||
console.error(`[${requestId}] Invalid submission status: ${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' }),
|
||||
{
|
||||
@@ -150,17 +250,59 @@ const handler = async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// STEP 6: Register idempotency key as processing
|
||||
// STEP 6: Register idempotency key as processing (atomic upsert)
|
||||
// ✅ CRITICAL FIX: Use ON CONFLICT to prevent race conditions
|
||||
if (!existingKey) {
|
||||
await supabase.from('submission_idempotency_keys').insert({
|
||||
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' } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[${requestId}] Calling process_approval_transaction RPC`);
|
||||
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.item_count': itemIds.length,
|
||||
}
|
||||
);
|
||||
|
||||
addSpanEvent(rpcSpan, 'rpc_call_start');
|
||||
edgeLogger.info('Calling approval transaction RPC', {
|
||||
requestId,
|
||||
submissionId,
|
||||
itemCount: itemIds.length,
|
||||
action: 'process_approval'
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// STEP 7: Call RPC function with deadlock retry logic
|
||||
@@ -178,7 +320,9 @@ const handler = async (req: Request) => {
|
||||
p_item_ids: itemIds,
|
||||
p_moderator_id: user.id,
|
||||
p_submitter_id: submission.user_id,
|
||||
p_request_id: requestId
|
||||
p_request_id: requestId,
|
||||
p_trace_id: rootSpan.traceId,
|
||||
p_parent_span_id: rpcSpan.spanId
|
||||
}
|
||||
);
|
||||
|
||||
@@ -187,6 +331,10 @@ const handler = async (req: Request) => {
|
||||
|
||||
if (!rpcError) {
|
||||
// Success!
|
||||
addSpanEvent(rpcSpan, 'rpc_call_success', {
|
||||
'result.status': data?.status,
|
||||
'items.processed': itemIds.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -194,23 +342,51 @@ const handler = async (req: Request) => {
|
||||
if (rpcError.code === '40P01' || rpcError.code === '40001') {
|
||||
retryCount++;
|
||||
if (retryCount > MAX_DEADLOCK_RETRIES) {
|
||||
console.error(`[${requestId}] Max deadlock retries exceeded`);
|
||||
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);
|
||||
console.log(`[${requestId}] Deadlock detected, retrying in ${backoffMs}ms (attempt ${retryCount}/${MAX_DEADLOCK_RETRIES})`);
|
||||
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
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (rpcError) {
|
||||
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
||||
console.error(`[${requestId}] Approval transaction failed:`, rpcError);
|
||||
endSpan(rpcSpan, 'error', rpcError);
|
||||
logSpan(rpcSpan);
|
||||
|
||||
edgeLogger.error('Transaction failed', {
|
||||
requestId,
|
||||
duration: rpcSpan.duration,
|
||||
submissionId,
|
||||
error: rpcError.message,
|
||||
errorCode: rpcError.code,
|
||||
retries: retryCount,
|
||||
action: 'process_approval'
|
||||
});
|
||||
|
||||
// Update idempotency key to failed
|
||||
try {
|
||||
@@ -223,10 +399,19 @@ const handler = async (req: Request) => {
|
||||
})
|
||||
.eq('idempotency_key', idempotencyKey);
|
||||
} catch (updateError) {
|
||||
console.error(`[${requestId}] Failed to update idempotency key to failed:`, 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);
|
||||
logSpan(rootSpan);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Approval transaction failed',
|
||||
@@ -244,7 +429,24 @@ const handler = async (req: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[${requestId}] Transaction completed successfully:`, result);
|
||||
// RPC succeeded
|
||||
endSpan(rpcSpan, 'ok');
|
||||
logSpan(rpcSpan);
|
||||
|
||||
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'
|
||||
});
|
||||
|
||||
// STEP 8: Success - update idempotency key
|
||||
try {
|
||||
@@ -257,10 +459,19 @@ const handler = async (req: Request) => {
|
||||
})
|
||||
.eq('idempotency_key', idempotencyKey);
|
||||
} catch (updateError) {
|
||||
console.error(`[${requestId}] Failed to update idempotency key to completed:`, updateError);
|
||||
edgeLogger.warn('Failed to update idempotency key', {
|
||||
requestId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
error: formatEdgeError(updateError),
|
||||
action: 'process_approval'
|
||||
});
|
||||
// Non-blocking - transaction succeeded, so continue with success response
|
||||
}
|
||||
|
||||
endSpan(rootSpan, 'ok');
|
||||
logSpan(rootSpan);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(result),
|
||||
{
|
||||
@@ -274,7 +485,16 @@ const handler = async (req: Request) => {
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[${requestId}] Unexpected error:`, 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_approval'
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
|
||||
4
supabase/functions/process-selective-rejection/cors.ts
Normal file
4
supabase/functions/process-selective-rejection/cors.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
518
supabase/functions/process-selective-rejection/index.ts
Normal file
518
supabase/functions/process-selective-rejection/index.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
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 { corsHeaders } from './cors.ts';
|
||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||
import {
|
||||
edgeLogger,
|
||||
startSpan,
|
||||
endSpan,
|
||||
addSpanEvent,
|
||||
setSpanAttributes,
|
||||
getSpanContext,
|
||||
logSpan,
|
||||
extractSpanContextFromHeaders,
|
||||
type Span
|
||||
} from '../_shared/logger.ts';
|
||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.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;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
// Main handler function
|
||||
const handler = async (req: Request) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: corsHeaders
|
||||
});
|
||||
}
|
||||
|
||||
// Extract parent span context from headers (if present)
|
||||
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
||||
|
||||
// Create root span for this edge function invocation
|
||||
const rootSpan = startSpan(
|
||||
'process-selective-rejection',
|
||||
'SERVER',
|
||||
parentSpanContext,
|
||||
{
|
||||
'http.method': 'POST',
|
||||
'function.name': 'process-selective-rejection',
|
||||
}
|
||||
);
|
||||
const requestId = rootSpan.spanId;
|
||||
|
||||
try {
|
||||
// STEP 1: Authentication
|
||||
addSpanEvent(rootSpan, 'authentication_start');
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
addSpanEvent(rootSpan, 'authentication_failed', { reason: 'missing_header' });
|
||||
endSpan(rootSpan, 'error');
|
||||
logSpan(rootSpan);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing Authorization header' }),
|
||||
{
|
||||
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_rejection'
|
||||
});
|
||||
endSpan(rootSpan, 'error', authError || new Error('Unauthorized'));
|
||||
logSpan(rootSpan);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setSpanAttributes(rootSpan, { 'user.id': user.id });
|
||||
addSpanEvent(rootSpan, 'authentication_success');
|
||||
edgeLogger.info('Rejection request received', {
|
||||
requestId,
|
||||
moderatorId: user.id,
|
||||
action: 'process_rejection'
|
||||
});
|
||||
|
||||
// STEP 2: Parse request
|
||||
addSpanEvent(rootSpan, 'validation_start');
|
||||
const body: RejectionRequest = await req.json();
|
||||
const { submissionId, itemIds, rejectionReason, idempotencyKey } = body;
|
||||
|
||||
if (!submissionId || !itemIds || itemIds.length === 0 || !rejectionReason) {
|
||||
addSpanEvent(rootSpan, 'validation_failed', {
|
||||
hasSubmissionId: !!submissionId,
|
||||
hasItemIds: !!itemIds,
|
||||
itemCount: itemIds?.length || 0,
|
||||
hasReason: !!rejectionReason,
|
||||
});
|
||||
edgeLogger.warn('Invalid request payload', {
|
||||
requestId,
|
||||
hasSubmissionId: !!submissionId,
|
||||
hasItemIds: !!itemIds,
|
||||
itemCount: itemIds?.length || 0,
|
||||
hasReason: !!rejectionReason,
|
||||
action: 'process_rejection'
|
||||
});
|
||||
endSpan(rootSpan, 'error');
|
||||
logSpan(rootSpan);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing required fields: submissionId, itemIds, rejectionReason' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setSpanAttributes(rootSpan, {
|
||||
'submission.id': submissionId,
|
||||
'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
|
||||
const { data: submission, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('user_id, status, assigned_to')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
if (submissionError || !submission) {
|
||||
addSpanEvent(rootSpan, 'submission_fetch_failed', { error: submissionError?.message });
|
||||
edgeLogger.error('Submission not found', {
|
||||
requestId,
|
||||
submissionId,
|
||||
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
|
||||
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||
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',
|
||||
'DATABASE',
|
||||
getSpanContext(rootSpan),
|
||||
{
|
||||
'db.operation': 'rpc',
|
||||
'db.function': 'process_rejection_transaction',
|
||||
'submission.id': submissionId,
|
||||
'submission.item_count': itemIds.length,
|
||||
}
|
||||
);
|
||||
|
||||
addSpanEvent(rpcSpan, 'rpc_call_start');
|
||||
edgeLogger.info('Calling rejection transaction RPC', {
|
||||
requestId,
|
||||
submissionId,
|
||||
itemCount: itemIds.length,
|
||||
action: 'process_rejection'
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// STEP 7: Call RPC function with deadlock retry logic
|
||||
// ============================================================================
|
||||
let retryCount = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
if (rpcError) {
|
||||
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
||||
endSpan(rpcSpan, 'error', rpcError);
|
||||
logSpan(rpcSpan);
|
||||
|
||||
edgeLogger.error('Transaction failed', {
|
||||
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);
|
||||
logSpan(rootSpan);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Rejection transaction failed',
|
||||
message: rpcError.message,
|
||||
details: rpcError.details,
|
||||
retries: retryCount
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// RPC succeeded
|
||||
endSpan(rpcSpan, 'ok');
|
||||
logSpan(rpcSpan);
|
||||
|
||||
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'
|
||||
});
|
||||
|
||||
// STEP 8: 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) {
|
||||
edgeLogger.warn('Failed to update idempotency key', {
|
||||
requestId,
|
||||
idempotencyKey,
|
||||
status: 'completed',
|
||||
error: formatEdgeError(updateError),
|
||||
action: 'process_rejection'
|
||||
});
|
||||
// Non-blocking - transaction succeeded, so continue with success response
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply rate limiting: 10 requests per minute per IP (standard tier)
|
||||
serve(withRateLimit(handler, rateLimiters.standard, corsHeaders));
|
||||
@@ -2,6 +2,7 @@ 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 { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -261,7 +262,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
} catch (error) {
|
||||
edgeLogger.error('Unexpected error in receive-inbound-email', {
|
||||
requestId: tracking.requestId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
return createErrorResponse(error, 500, corsHeaders);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { edgeLogger } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -55,7 +56,7 @@ serve(async (req: Request) => {
|
||||
} catch (error) {
|
||||
edgeLogger.error('Maintenance exception', {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
|
||||
return new Response(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -237,7 +238,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
} catch (error) {
|
||||
edgeLogger.error('Unexpected error in send-admin-email-reply', {
|
||||
requestId: tracking.requestId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
return createErrorResponse(error, 500, corsHeaders);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { edgeLogger } from "../_shared/logger.ts";
|
||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -335,7 +336,7 @@ The ThrillWiki Team`,
|
||||
edgeLogger.error('Contact submission failed', {
|
||||
requestId,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
return createErrorResponse(error, 500, corsHeaders);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||
import { edgeLogger } from '../_shared/logger.ts';
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
||||
|
||||
const BASE_URL = 'https://dev.thrillwiki.com';
|
||||
|
||||
@@ -347,7 +348,7 @@ Deno.serve(async (req) => {
|
||||
|
||||
edgeLogger.error('Sitemap generation failed', {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
duration,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'
|
||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'
|
||||
import { formatEdgeError } from '../_shared/errorFormatter.ts'
|
||||
|
||||
// Environment-aware CORS configuration
|
||||
const getAllowedOrigin = (requestOrigin: string | null): string | null => {
|
||||
@@ -94,7 +95,7 @@ async function reportBanEvasionToAlerts(
|
||||
} catch (error) {
|
||||
// Non-blocking - log but don't fail the response
|
||||
edgeLogger.warn('Failed to report ban evasion', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatEdgeError(error),
|
||||
requestId
|
||||
});
|
||||
}
|
||||
@@ -227,7 +228,7 @@ serve(withRateLimit(async (req) => {
|
||||
try {
|
||||
requestBody = await req.json();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatEdgeError(error);
|
||||
edgeLogger.error('Invalid JSON in delete request', {
|
||||
error: errorMessage,
|
||||
requestId: tracking.requestId
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
|
||||
import { edgeLogger } from "../_shared/logger.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -96,7 +97,7 @@ serve(async (req) => {
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = endRequest(tracking);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatEdgeError(error);
|
||||
edgeLogger.error('Email validation error', {
|
||||
error: errorMessage,
|
||||
requestId: tracking.requestId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { startRequest, endRequest, edgeLogger } from "../_shared/logger.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -180,7 +181,7 @@ const handler = async (req: Request): Promise<Response> => {
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = formatEdgeError(error);
|
||||
edgeLogger.error('Error in validate-email function', {
|
||||
error: errorMessage,
|
||||
requestId: tracking.requestId
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
-- ============================================================================
|
||||
-- CRITICAL: Add Atomic Rejection Transaction RPC
|
||||
-- ============================================================================
|
||||
-- This migration creates process_rejection_transaction to ensure atomic
|
||||
-- rejection of submission items with proper audit logging and status updates.
|
||||
--
|
||||
-- Features:
|
||||
-- - Atomic updates to submission_items.status = 'rejected'
|
||||
-- - Sets rejection_reason for each item
|
||||
-- - Updates parent submission status (rejected or partially_approved)
|
||||
-- - Logs to moderation_audit_log
|
||||
-- - Releases lock (assigned_to = NULL, locked_until = NULL)
|
||||
-- - Returns transaction result
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_rejection_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_rejection_reason TEXT,
|
||||
p_request_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start_time TIMESTAMPTZ;
|
||||
v_result JSONB;
|
||||
v_rejected_count INTEGER := 0;
|
||||
v_final_status TEXT;
|
||||
v_some_pending BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
v_start_time := clock_timestamp();
|
||||
|
||||
RAISE NOTICE '[%] Starting atomic rejection transaction for submission %',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 1: Set session variables (transaction-scoped)
|
||||
-- ========================================================================
|
||||
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 2: Validate submission ownership and lock status
|
||||
-- ========================================================================
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM content_submissions
|
||||
WHERE id = p_submission_id
|
||||
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
|
||||
AND status IN ('pending', 'partially_approved')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 3: Update all items to rejected atomically
|
||||
-- ========================================================================
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'rejected',
|
||||
rejection_reason = p_rejection_reason,
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY(p_item_ids)
|
||||
AND submission_id = p_submission_id
|
||||
AND status IN ('pending', 'rejected');
|
||||
|
||||
GET DIAGNOSTICS v_rejected_count = ROW_COUNT;
|
||||
|
||||
RAISE NOTICE '[%] Rejected % items',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_rejected_count;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 4: Determine final submission status
|
||||
-- ========================================================================
|
||||
-- Check if any items are still pending
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM submission_items
|
||||
WHERE submission_id = p_submission_id
|
||||
AND status = 'pending'
|
||||
) INTO v_some_pending;
|
||||
|
||||
-- Set final status
|
||||
v_final_status := CASE
|
||||
WHEN v_some_pending THEN 'partially_approved'
|
||||
WHEN EXISTS(
|
||||
SELECT 1 FROM submission_items
|
||||
WHERE submission_id = p_submission_id
|
||||
AND status = 'approved'
|
||||
) THEN 'partially_approved'
|
||||
ELSE 'rejected'
|
||||
END;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 5: Update parent submission
|
||||
-- ========================================================================
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = v_final_status,
|
||||
reviewer_id = p_moderator_id,
|
||||
reviewed_at = NOW(),
|
||||
assigned_to = NULL,
|
||||
locked_until = NULL,
|
||||
reviewer_notes = p_rejection_reason
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 6: Log to moderation_audit_log
|
||||
-- ========================================================================
|
||||
INSERT INTO moderation_audit_log (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
action,
|
||||
details,
|
||||
created_at
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
'rejection',
|
||||
jsonb_build_object(
|
||||
'item_ids', p_item_ids,
|
||||
'rejection_reason', p_rejection_reason,
|
||||
'rejected_count', v_rejected_count,
|
||||
'final_status', v_final_status,
|
||||
'request_id', p_request_id
|
||||
),
|
||||
NOW()
|
||||
);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 7: Build result
|
||||
-- ========================================================================
|
||||
v_result := jsonb_build_object(
|
||||
'success', TRUE,
|
||||
'rejected_count', v_rejected_count,
|
||||
'submission_status', v_final_status,
|
||||
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||
);
|
||||
|
||||
-- Clear session variables
|
||||
PERFORM set_config('app.moderator_id', '', true);
|
||||
|
||||
RAISE NOTICE '[%] Rejection transaction completed in %ms',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Grant execute permissions
|
||||
GRANT EXECUTE ON FUNCTION process_rejection_transaction TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION process_rejection_transaction IS
|
||||
'Atomic rejection transaction with audit logging and lock release';
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Fix create_submission_with_items to remove temp_location_data reference
|
||||
-- This column was dropped but the function still references it, causing park submissions to fail
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_submission_with_items(uuid, text, text, jsonb, uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_submission_with_items(
|
||||
p_submission_id uuid,
|
||||
p_entity_type text,
|
||||
p_action_type text,
|
||||
p_items jsonb,
|
||||
p_user_id uuid
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_item JSONB;
|
||||
v_item_type TEXT;
|
||||
v_item_data JSONB;
|
||||
v_depends_on INTEGER;
|
||||
v_order_index INTEGER;
|
||||
v_created_ids UUID[] := ARRAY[]::UUID[];
|
||||
v_submission_item_id UUID;
|
||||
v_entity_submission_id UUID;
|
||||
BEGIN
|
||||
-- Loop through items array
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_item_type := v_item->>'item_type';
|
||||
v_item_data := v_item->'item_data';
|
||||
v_depends_on := (v_item->>'depends_on')::INTEGER;
|
||||
v_order_index := (v_item->>'order_index')::INTEGER;
|
||||
|
||||
-- Resolve dependency references
|
||||
IF v_depends_on IS NOT NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object(
|
||||
v_item->>'dependency_field',
|
||||
v_created_ids[v_depends_on + 1]
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Create submission based on entity type
|
||||
IF v_item_type = 'park' THEN
|
||||
INSERT INTO park_submissions (
|
||||
submission_id, name, slug, description, park_type, status,
|
||||
opening_date, opening_date_precision, closing_date, closing_date_precision,
|
||||
location_id, operator_id, property_owner_id,
|
||||
website_url, phone, email,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_item_data->>'description',
|
||||
v_item_data->>'park_type',
|
||||
v_item_data->>'status',
|
||||
(v_item_data->>'opening_date')::DATE,
|
||||
v_item_data->>'opening_date_precision',
|
||||
(v_item_data->>'closing_date')::DATE,
|
||||
v_item_data->>'closing_date_precision',
|
||||
(v_item_data->>'location_id')::UUID,
|
||||
(v_item_data->>'operator_id')::UUID,
|
||||
(v_item_data->>'property_owner_id')::UUID,
|
||||
v_item_data->>'website_url',
|
||||
v_item_data->>'phone',
|
||||
v_item_data->>'email',
|
||||
v_item_data->>'banner_image_url',
|
||||
v_item_data->>'banner_image_id',
|
||||
v_item_data->>'card_image_url',
|
||||
v_item_data->>'card_image_id'
|
||||
) RETURNING id INTO v_entity_submission_id;
|
||||
|
||||
ELSIF v_item_type = 'ride' THEN
|
||||
INSERT INTO ride_submissions (
|
||||
submission_id, name, slug, description, category, status,
|
||||
opening_date, opening_date_precision, closing_date, closing_date_precision,
|
||||
park_id, manufacturer_id, designer_id, ride_model_id,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_item_data->>'description',
|
||||
v_item_data->>'category',
|
||||
v_item_data->>'status',
|
||||
(v_item_data->>'opening_date')::DATE,
|
||||
v_item_data->>'opening_date_precision',
|
||||
(v_item_data->>'closing_date')::DATE,
|
||||
v_item_data->>'closing_date_precision',
|
||||
(v_item_data->>'park_id')::UUID,
|
||||
(v_item_data->>'manufacturer_id')::UUID,
|
||||
(v_item_data->>'designer_id')::UUID,
|
||||
(v_item_data->>'ride_model_id')::UUID,
|
||||
v_item_data->>'banner_image_url',
|
||||
v_item_data->>'banner_image_id',
|
||||
v_item_data->>'card_image_url',
|
||||
v_item_data->>'card_image_id'
|
||||
) RETURNING id INTO v_entity_submission_id;
|
||||
|
||||
ELSIF v_item_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN
|
||||
INSERT INTO company_submissions (
|
||||
submission_id, name, slug, description, company_type,
|
||||
founded_year, headquarters_location, website_url,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_item_data->>'description',
|
||||
v_item_type,
|
||||
(v_item_data->>'founded_year')::INTEGER,
|
||||
v_item_data->>'headquarters_location',
|
||||
v_item_data->>'website_url',
|
||||
v_item_data->>'banner_image_url',
|
||||
v_item_data->>'banner_image_id',
|
||||
v_item_data->>'card_image_url',
|
||||
v_item_data->>'card_image_id'
|
||||
) RETURNING id INTO v_entity_submission_id;
|
||||
|
||||
ELSIF v_item_type = 'ride_model' THEN
|
||||
INSERT INTO ride_model_submissions (
|
||||
submission_id, name, slug, description, manufacturer_id, category,
|
||||
banner_image_url, banner_image_id, card_image_url, card_image_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
v_item_data->>'name',
|
||||
v_item_data->>'slug',
|
||||
v_item_data->>'description',
|
||||
(v_item_data->>'manufacturer_id')::UUID,
|
||||
v_item_data->>'category',
|
||||
v_item_data->>'banner_image_url',
|
||||
v_item_data->>'banner_image_id',
|
||||
v_item_data->>'card_image_url',
|
||||
v_item_data->>'card_image_id'
|
||||
) RETURNING id INTO v_entity_submission_id;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unsupported item type: %', v_item_type;
|
||||
END IF;
|
||||
|
||||
-- Create submission_item record linking to the entity submission
|
||||
INSERT INTO submission_items (
|
||||
submission_id,
|
||||
item_type,
|
||||
action_type,
|
||||
order_index,
|
||||
depends_on,
|
||||
park_submission_id,
|
||||
ride_submission_id,
|
||||
company_submission_id,
|
||||
ride_model_submission_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
v_item_type,
|
||||
p_action_type,
|
||||
v_order_index,
|
||||
CASE WHEN v_depends_on IS NOT NULL THEN v_created_ids[v_depends_on + 1] ELSE NULL END,
|
||||
CASE WHEN v_item_type = 'park' THEN v_entity_submission_id ELSE NULL END,
|
||||
CASE WHEN v_item_type = 'ride' THEN v_entity_submission_id ELSE NULL END,
|
||||
CASE WHEN v_item_type IN ('manufacturer', 'operator', 'designer', 'property_owner') THEN v_entity_submission_id ELSE NULL END,
|
||||
CASE WHEN v_item_type = 'ride_model' THEN v_entity_submission_id ELSE NULL END
|
||||
) RETURNING id INTO v_submission_item_id;
|
||||
|
||||
-- Track created submission item IDs in order for dependency resolution
|
||||
v_created_ids := array_append(v_created_ids, v_submission_item_id);
|
||||
END LOOP;
|
||||
|
||||
RETURN p_submission_id;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,474 @@
|
||||
-- ============================================================================
|
||||
-- CRITICAL FIX: Restore complete approval pipeline with tracing
|
||||
-- ============================================================================
|
||||
-- This fixes the broken RPC that deleted all non-park entity handling
|
||||
-- and restores full functionality for all entity types
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_trace_id TEXT DEFAULT NULL,
|
||||
p_parent_span_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start_time TIMESTAMPTZ;
|
||||
v_result JSONB;
|
||||
v_item RECORD;
|
||||
v_item_data JSONB;
|
||||
v_resolved_refs JSONB;
|
||||
v_entity_id UUID;
|
||||
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||
v_final_status TEXT;
|
||||
v_all_approved BOOLEAN := TRUE;
|
||||
v_some_approved BOOLEAN := FALSE;
|
||||
v_items_processed INTEGER := 0;
|
||||
v_span_id TEXT;
|
||||
BEGIN
|
||||
v_start_time := clock_timestamp();
|
||||
v_span_id := gen_random_uuid()::text;
|
||||
|
||||
-- Log span start with trace context
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
v_span_id,
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
EXTRACT(EPOCH FROM v_start_time) * 1000,
|
||||
p_submission_id,
|
||||
array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Starting atomic approval transaction for submission %',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 1: Set session variables (transaction-scoped with is_local=true)
|
||||
-- ========================================================================
|
||||
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 2: Validate submission ownership and lock status
|
||||
-- ========================================================================
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM content_submissions
|
||||
WHERE id = p_submission_id
|
||||
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
|
||||
AND status IN ('pending', 'partially_approved')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 3: Process each item sequentially within this transaction
|
||||
-- ========================================================================
|
||||
FOR v_item IN
|
||||
SELECT
|
||||
si.*,
|
||||
ps.name as park_name,
|
||||
ps.slug as park_slug,
|
||||
ps.description as park_description,
|
||||
ps.park_type,
|
||||
ps.status as park_status,
|
||||
ps.location_id,
|
||||
ps.operator_id,
|
||||
ps.property_owner_id,
|
||||
ps.opening_date as park_opening_date,
|
||||
ps.closing_date as park_closing_date,
|
||||
ps.opening_date_precision as park_opening_date_precision,
|
||||
ps.closing_date_precision as park_closing_date_precision,
|
||||
ps.website_url as park_website_url,
|
||||
ps.phone as park_phone,
|
||||
ps.email as park_email,
|
||||
ps.banner_image_url as park_banner_image_url,
|
||||
ps.banner_image_id as park_banner_image_id,
|
||||
ps.card_image_url as park_card_image_url,
|
||||
ps.card_image_id as park_card_image_id,
|
||||
rs.name as ride_name,
|
||||
rs.slug as ride_slug,
|
||||
rs.park_id as ride_park_id,
|
||||
rs.ride_type,
|
||||
rs.status as ride_status,
|
||||
rs.manufacturer_id,
|
||||
rs.ride_model_id,
|
||||
rs.opening_date as ride_opening_date,
|
||||
rs.closing_date as ride_closing_date,
|
||||
rs.opening_date_precision as ride_opening_date_precision,
|
||||
rs.closing_date_precision as ride_closing_date_precision,
|
||||
rs.description as ride_description,
|
||||
rs.banner_image_url as ride_banner_image_url,
|
||||
rs.banner_image_id as ride_banner_image_id,
|
||||
rs.card_image_url as ride_card_image_url,
|
||||
rs.card_image_id as ride_card_image_id,
|
||||
cs.name as company_name,
|
||||
cs.slug as company_slug,
|
||||
cs.description as company_description,
|
||||
cs.website_url as company_website_url,
|
||||
cs.founded_year,
|
||||
cs.banner_image_url as company_banner_image_url,
|
||||
cs.banner_image_id as company_banner_image_id,
|
||||
cs.card_image_url as company_card_image_url,
|
||||
cs.card_image_id as company_card_image_id,
|
||||
rms.name as ride_model_name,
|
||||
rms.slug as ride_model_slug,
|
||||
rms.manufacturer_id as ride_model_manufacturer_id,
|
||||
rms.ride_type as ride_model_ride_type,
|
||||
rms.description as ride_model_description,
|
||||
rms.banner_image_url as ride_model_banner_image_url,
|
||||
rms.banner_image_id as ride_model_banner_image_id,
|
||||
rms.card_image_url as ride_model_card_image_url,
|
||||
rms.card_image_id as ride_model_card_image_id,
|
||||
phs.entity_id as photo_entity_id,
|
||||
phs.entity_type as photo_entity_type,
|
||||
phs.title as photo_title
|
||||
FROM submission_items si
|
||||
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
|
||||
WHERE si.id = ANY(p_item_ids)
|
||||
ORDER BY si.order_index, si.created_at
|
||||
LOOP
|
||||
BEGIN
|
||||
v_items_processed := v_items_processed + 1;
|
||||
|
||||
-- Log item processing span event
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||
p_trace_id,
|
||||
v_span_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Build item data based on entity type
|
||||
IF v_item.item_type = 'park' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.park_name,
|
||||
'slug', v_item.park_slug,
|
||||
'description', v_item.park_description,
|
||||
'park_type', v_item.park_type,
|
||||
'status', v_item.park_status,
|
||||
'location_id', v_item.location_id,
|
||||
'operator_id', v_item.operator_id,
|
||||
'property_owner_id', v_item.property_owner_id,
|
||||
'opening_date', v_item.park_opening_date,
|
||||
'closing_date', v_item.park_closing_date,
|
||||
'opening_date_precision', v_item.park_opening_date_precision,
|
||||
'closing_date_precision', v_item.park_closing_date_precision,
|
||||
'website_url', v_item.park_website_url,
|
||||
'phone', v_item.park_phone,
|
||||
'email', v_item.park_email,
|
||||
'banner_image_url', v_item.park_banner_image_url,
|
||||
'banner_image_id', v_item.park_banner_image_id,
|
||||
'card_image_url', v_item.park_card_image_url,
|
||||
'card_image_id', v_item.park_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_name,
|
||||
'slug', v_item.ride_slug,
|
||||
'park_id', v_item.ride_park_id,
|
||||
'ride_type', v_item.ride_type,
|
||||
'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
|
||||
);
|
||||
ELSIF v_item.item_type IN ('manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.company_name,
|
||||
'slug', v_item.company_slug,
|
||||
'description', v_item.company_description,
|
||||
'website_url', v_item.company_website_url,
|
||||
'founded_year', v_item.founded_year,
|
||||
'banner_image_url', v_item.company_banner_image_url,
|
||||
'banner_image_id', v_item.company_banner_image_id,
|
||||
'card_image_url', v_item.company_card_image_url,
|
||||
'card_image_id', v_item.company_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride_model' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_model_name,
|
||||
'slug', v_item.ride_model_slug,
|
||||
'manufacturer_id', v_item.ride_model_manufacturer_id,
|
||||
'ride_type', v_item.ride_model_ride_type,
|
||||
'description', v_item.ride_model_description,
|
||||
'banner_image_url', v_item.ride_model_banner_image_url,
|
||||
'banner_image_id', v_item.ride_model_banner_image_id,
|
||||
'card_image_url', v_item.ride_model_card_image_url,
|
||||
'card_image_id', v_item.ride_model_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'photo' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'entity_id', v_item.photo_entity_id,
|
||||
'entity_type', v_item.photo_entity_type,
|
||||
'title', v_item.photo_title,
|
||||
'photo_submission_id', v_item.photo_submission_id
|
||||
);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unsupported item_type: %', v_item.item_type;
|
||||
END IF;
|
||||
|
||||
-- ======================================================================
|
||||
-- Resolve temp refs and update v_item_data with actual entity IDs
|
||||
-- ======================================================================
|
||||
v_resolved_refs := resolve_temp_refs_for_item(v_item.id, p_submission_id);
|
||||
|
||||
IF v_resolved_refs IS NOT NULL AND jsonb_typeof(v_resolved_refs) = 'object' THEN
|
||||
-- Replace NULL foreign keys with resolved entity IDs
|
||||
-- For parks: operator_id, property_owner_id
|
||||
IF v_item.item_type = 'park' THEN
|
||||
IF v_resolved_refs ? 'operator' AND (v_item_data->>'operator_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('operator_id', v_resolved_refs->>'operator');
|
||||
RAISE NOTICE 'Resolved park.operator_id → %', v_resolved_refs->>'operator';
|
||||
END IF;
|
||||
IF v_resolved_refs ? 'property_owner' AND (v_item_data->>'property_owner_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('property_owner_id', v_resolved_refs->>'property_owner');
|
||||
RAISE NOTICE 'Resolved park.property_owner_id → %', v_resolved_refs->>'property_owner';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- For rides: park_id, manufacturer_id, ride_model_id
|
||||
IF v_item.item_type = 'ride' THEN
|
||||
IF v_resolved_refs ? 'park' AND (v_item_data->>'park_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('park_id', v_resolved_refs->>'park');
|
||||
RAISE NOTICE 'Resolved ride.park_id → %', v_resolved_refs->>'park';
|
||||
END IF;
|
||||
IF v_resolved_refs ? 'manufacturer' AND (v_item_data->>'manufacturer_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('manufacturer_id', v_resolved_refs->>'manufacturer');
|
||||
RAISE NOTICE 'Resolved ride.manufacturer_id → %', v_resolved_refs->>'manufacturer';
|
||||
END IF;
|
||||
IF v_resolved_refs ? 'ride_model' AND (v_item_data->>'ride_model_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('ride_model_id', v_resolved_refs->>'ride_model');
|
||||
RAISE NOTICE 'Resolved ride.ride_model_id → %', v_resolved_refs->>'ride_model';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- For ride_models: manufacturer_id
|
||||
IF v_item.item_type = 'ride_model' THEN
|
||||
IF v_resolved_refs ? 'manufacturer' AND (v_item_data->>'manufacturer_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('manufacturer_id', v_resolved_refs->>'manufacturer');
|
||||
RAISE NOTICE 'Resolved ride_model.manufacturer_id → %', v_resolved_refs->>'manufacturer';
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Execute action based on action_type and item_type
|
||||
IF v_item.action_type = 'create' THEN
|
||||
IF v_item.item_type = 'photo' THEN
|
||||
-- Insert all photo_submission_items as photos atomically
|
||||
INSERT INTO photos (
|
||||
entity_id, entity_type, cloudflare_image_id, cloudflare_image_url,
|
||||
title, caption, date_taken, date_taken_precision, order_index,
|
||||
submission_id, submitted_by, approved_by, approved_at
|
||||
)
|
||||
SELECT
|
||||
(v_item_data->>'entity_id')::UUID,
|
||||
v_item_data->>'entity_type',
|
||||
psi.cloudflare_image_id,
|
||||
psi.cloudflare_image_url,
|
||||
psi.title,
|
||||
psi.caption,
|
||||
psi.date_taken,
|
||||
psi.date_taken_precision,
|
||||
psi.order_index,
|
||||
p_submission_id,
|
||||
p_submitter_id,
|
||||
p_moderator_id,
|
||||
NOW()
|
||||
FROM photo_submission_items psi
|
||||
WHERE psi.photo_submission_id = (v_item_data->>'photo_submission_id')::UUID;
|
||||
|
||||
-- Return the photo_submission entity_id as v_entity_id
|
||||
v_entity_id := (v_item_data->>'entity_id')::UUID;
|
||||
|
||||
RAISE NOTICE '[%] Created % photos for entity % (type=%)',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
(SELECT COUNT(*) FROM photo_submission_items WHERE photo_submission_id = (v_item_data->>'photo_submission_id')::UUID),
|
||||
v_entity_id,
|
||||
v_item_data->>'entity_type';
|
||||
ELSE
|
||||
-- Standard entity creation - FIXED: Pass v_item_data instead of v_item.id
|
||||
v_entity_id := create_entity_from_submission(
|
||||
v_item.item_type,
|
||||
v_item_data,
|
||||
p_submitter_id
|
||||
);
|
||||
END IF;
|
||||
ELSIF v_item.action_type = 'update' THEN
|
||||
v_entity_id := update_entity_from_submission(
|
||||
v_item.item_type,
|
||||
v_item_data,
|
||||
v_item.target_entity_id,
|
||||
p_submitter_id
|
||||
);
|
||||
ELSIF v_item.action_type = 'delete' THEN
|
||||
PERFORM delete_entity_from_submission(
|
||||
v_item.item_type,
|
||||
v_item.target_entity_id,
|
||||
p_submitter_id
|
||||
);
|
||||
v_entity_id := v_item.target_entity_id;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown action_type: %', v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Update submission_item to approved status
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'approved',
|
||||
approved_entity_id = v_entity_id,
|
||||
updated_at = NOW()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Track success
|
||||
v_approval_results := array_append(
|
||||
v_approval_results,
|
||||
jsonb_build_object(
|
||||
'itemId', v_item.id,
|
||||
'entityId', v_entity_id,
|
||||
'itemType', v_item.item_type,
|
||||
'actionType', v_item.action_type,
|
||||
'success', true
|
||||
)
|
||||
);
|
||||
|
||||
v_some_approved := TRUE;
|
||||
|
||||
RAISE NOTICE '[%] Approved item % (type=%s, action=%s, entityId=%s)',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action_type,
|
||||
v_entity_id;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Log error but continue processing remaining items
|
||||
RAISE WARNING '[%] Item % failed: % (SQLSTATE: %)',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_item.id,
|
||||
SQLERRM,
|
||||
SQLSTATE;
|
||||
|
||||
-- Update submission_item to rejected status
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'rejected',
|
||||
rejection_reason = SQLERRM,
|
||||
updated_at = NOW()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Track failure
|
||||
v_approval_results := array_append(
|
||||
v_approval_results,
|
||||
jsonb_build_object(
|
||||
'itemId', v_item.id,
|
||||
'itemType', v_item.item_type,
|
||||
'actionType', v_item.action_type,
|
||||
'success', false,
|
||||
'error', SQLERRM
|
||||
)
|
||||
);
|
||||
|
||||
v_all_approved := FALSE;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 4: Determine final submission status
|
||||
-- ========================================================================
|
||||
v_final_status := CASE
|
||||
WHEN v_all_approved THEN 'approved'
|
||||
WHEN v_some_approved THEN 'partially_approved'
|
||||
ELSE 'rejected'
|
||||
END;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 5: Update submission status
|
||||
-- ========================================================================
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = v_final_status,
|
||||
reviewer_id = p_moderator_id,
|
||||
reviewed_at = NOW(),
|
||||
assigned_to = NULL,
|
||||
locked_until = NULL
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 6: Log metrics
|
||||
-- ========================================================================
|
||||
INSERT INTO approval_transaction_metrics (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
submitter_id,
|
||||
items_count,
|
||||
duration_ms,
|
||||
success,
|
||||
request_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
p_submitter_id,
|
||||
v_items_processed,
|
||||
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000,
|
||||
v_all_approved,
|
||||
p_request_id
|
||||
);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 7: Build result
|
||||
-- ========================================================================
|
||||
v_result := jsonb_build_object(
|
||||
'success', v_all_approved,
|
||||
'submissionId', p_submission_id,
|
||||
'finalStatus', v_final_status,
|
||||
'itemsProcessed', v_items_processed,
|
||||
'results', v_approval_results,
|
||||
'durationMs', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||
);
|
||||
|
||||
-- Log span completion
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "transaction_complete", "timestamp": %, "attributes": {"items_processed": %, "final_status": "%"}}',
|
||||
p_trace_id,
|
||||
v_span_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_items_processed,
|
||||
v_final_status;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Transaction complete: % items processed, status=%s',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_items_processed,
|
||||
v_final_status;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,227 @@
|
||||
-- Add distributed tracing support to RPC functions
|
||||
-- Adds trace_id and parent_span_id parameters for span context propagation
|
||||
|
||||
-- Update process_approval_transaction to accept trace context
|
||||
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_item submission_items;
|
||||
v_approved_count INTEGER := 0;
|
||||
v_total_items INTEGER;
|
||||
v_new_status TEXT;
|
||||
v_entity_id UUID;
|
||||
v_all_items_processed BOOLEAN;
|
||||
BEGIN
|
||||
-- Log span start with trace context
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
gen_random_uuid()::text,
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
extract(epoch from clock_timestamp()) * 1000,
|
||||
p_submission_id,
|
||||
array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
-- Get total items for this submission
|
||||
SELECT COUNT(*) INTO v_total_items
|
||||
FROM submission_items
|
||||
WHERE submission_id = p_submission_id;
|
||||
|
||||
-- Process each item
|
||||
FOREACH v_item IN ARRAY (
|
||||
SELECT ARRAY_AGG(si ORDER BY si.order_index)
|
||||
FROM submission_items si
|
||||
WHERE si.id = ANY(p_item_ids)
|
||||
)
|
||||
LOOP
|
||||
-- Log item processing span event
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
extract(epoch from clock_timestamp()) * 1000,
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action;
|
||||
END IF;
|
||||
|
||||
-- Create or update entity based on item type
|
||||
IF v_item.item_type = 'park' THEN
|
||||
IF v_item.action = 'create' THEN
|
||||
-- Log entity creation
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "name": "create_entity_park", "timestamp": %, "attributes": {"action": "create"}}',
|
||||
p_trace_id,
|
||||
extract(epoch from clock_timestamp()) * 1000;
|
||||
END IF;
|
||||
|
||||
v_entity_id := create_entity_from_submission('park', v_item.id, p_submitter_id, p_request_id);
|
||||
ELSIF v_item.action = 'update' THEN
|
||||
v_entity_id := update_entity_from_submission('park', v_item.id, v_item.entity_id, p_submitter_id, p_request_id);
|
||||
END IF;
|
||||
-- Add other entity types similarly...
|
||||
END IF;
|
||||
|
||||
-- Update item status
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'approved',
|
||||
processed_at = NOW(),
|
||||
processed_by = p_moderator_id,
|
||||
entity_id = v_entity_id
|
||||
WHERE id = v_item.id;
|
||||
|
||||
v_approved_count := v_approved_count + 1;
|
||||
END LOOP;
|
||||
|
||||
-- Determine final submission status
|
||||
SELECT
|
||||
COUNT(*) = array_length(p_item_ids, 1)
|
||||
INTO v_all_items_processed
|
||||
FROM submission_items
|
||||
WHERE submission_id = p_submission_id
|
||||
AND status IN ('approved', 'rejected');
|
||||
|
||||
IF v_all_items_processed THEN
|
||||
v_new_status := 'approved';
|
||||
ELSE
|
||||
v_new_status := 'partially_approved';
|
||||
END IF;
|
||||
|
||||
-- Update submission status
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = v_new_status,
|
||||
processed_at = CASE WHEN v_new_status = 'approved' THEN NOW() ELSE processed_at END,
|
||||
assigned_to = NULL,
|
||||
lock_expires_at = NULL
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- Log completion
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "name": "transaction_complete", "timestamp": %, "attributes": {"items_processed": %, "new_status": "%"}}',
|
||||
p_trace_id,
|
||||
extract(epoch from clock_timestamp()) * 1000,
|
||||
v_approved_count,
|
||||
v_new_status;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'status', v_new_status,
|
||||
'approved_count', v_approved_count,
|
||||
'total_items', v_total_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update process_rejection_transaction similarly
|
||||
CREATE OR REPLACE FUNCTION process_rejection_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_rejection_reason TEXT,
|
||||
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_rejected_count INTEGER := 0;
|
||||
v_total_items INTEGER;
|
||||
v_new_status TEXT;
|
||||
v_all_items_processed BOOLEAN;
|
||||
BEGIN
|
||||
-- Log span start
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_rejection_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
gen_random_uuid()::text,
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
extract(epoch from clock_timestamp()) * 1000,
|
||||
p_submission_id,
|
||||
array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
-- Get total items
|
||||
SELECT COUNT(*) INTO v_total_items
|
||||
FROM submission_items
|
||||
WHERE submission_id = p_submission_id;
|
||||
|
||||
-- Reject items
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'rejected',
|
||||
rejection_reason = p_rejection_reason,
|
||||
processed_at = NOW(),
|
||||
processed_by = p_moderator_id
|
||||
WHERE id = ANY(p_item_ids);
|
||||
|
||||
GET DIAGNOSTICS v_rejected_count = ROW_COUNT;
|
||||
|
||||
-- Check if all items processed
|
||||
SELECT
|
||||
COUNT(*) = (SELECT COUNT(*) FROM submission_items WHERE submission_id = p_submission_id)
|
||||
INTO v_all_items_processed
|
||||
FROM submission_items
|
||||
WHERE submission_id = p_submission_id
|
||||
AND status IN ('approved', 'rejected');
|
||||
|
||||
IF v_all_items_processed THEN
|
||||
-- Check if any items were approved
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM submission_items
|
||||
WHERE submission_id = p_submission_id AND status = 'approved'
|
||||
) INTO v_all_items_processed;
|
||||
|
||||
v_new_status := CASE
|
||||
WHEN v_all_items_processed THEN 'partially_approved'
|
||||
ELSE 'rejected'
|
||||
END;
|
||||
ELSE
|
||||
v_new_status := 'partially_approved';
|
||||
END IF;
|
||||
|
||||
-- Update submission
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = v_new_status,
|
||||
processed_at = CASE WHEN v_new_status = 'rejected' THEN NOW() ELSE processed_at END,
|
||||
assigned_to = NULL,
|
||||
lock_expires_at = NULL
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- Log completion
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "name": "rejection_complete", "timestamp": %, "attributes": {"items_rejected": %, "new_status": "%"}}',
|
||||
p_trace_id,
|
||||
extract(epoch from clock_timestamp()) * 1000,
|
||||
v_rejected_count,
|
||||
v_new_status;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'status', v_new_status,
|
||||
'rejected_count', v_rejected_count,
|
||||
'total_items', v_total_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,444 @@
|
||||
-- ============================================================================
|
||||
-- Phase 8: Fix RLS Policies, SQL Schema, and Rate Limiting
|
||||
-- ============================================================================
|
||||
-- This migration addresses critical test failures:
|
||||
-- 1. Adds missing INSERT policies for submission tables (26 tests)
|
||||
-- 2. Fixes ride_type → category column references (2 tests)
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 1: Add INSERT Policies for Submission Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Park Submissions: Users can insert their own submissions
|
||||
CREATE POLICY "park_submissions_insert_own"
|
||||
ON public.park_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM content_submissions cs
|
||||
WHERE cs.id = submission_id
|
||||
AND cs.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Park Submissions: Moderators can insert any submissions
|
||||
CREATE POLICY "park_submissions_insert_moderators"
|
||||
ON public.park_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
is_moderator(auth.uid())
|
||||
);
|
||||
|
||||
-- Ride Submissions: Users can insert their own submissions
|
||||
CREATE POLICY "ride_submissions_insert_own"
|
||||
ON public.ride_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM content_submissions cs
|
||||
WHERE cs.id = submission_id
|
||||
AND cs.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Ride Submissions: Moderators can insert any submissions
|
||||
CREATE POLICY "ride_submissions_insert_moderators"
|
||||
ON public.ride_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
is_moderator(auth.uid())
|
||||
);
|
||||
|
||||
-- Company Submissions: Users can insert their own submissions
|
||||
CREATE POLICY "company_submissions_insert_own"
|
||||
ON public.company_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM content_submissions cs
|
||||
WHERE cs.id = submission_id
|
||||
AND cs.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Company Submissions: Moderators can insert any submissions
|
||||
CREATE POLICY "company_submissions_insert_moderators"
|
||||
ON public.company_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
is_moderator(auth.uid())
|
||||
);
|
||||
|
||||
-- Ride Model Submissions: Users can insert their own submissions
|
||||
CREATE POLICY "ride_model_submissions_insert_own"
|
||||
ON public.ride_model_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM content_submissions cs
|
||||
WHERE cs.id = submission_id
|
||||
AND cs.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Ride Model Submissions: Moderators can insert any submissions
|
||||
CREATE POLICY "ride_model_submissions_insert_moderators"
|
||||
ON public.ride_model_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
is_moderator(auth.uid())
|
||||
);
|
||||
|
||||
-- Photo Submissions: Users can insert their own submissions
|
||||
CREATE POLICY "photo_submissions_insert_own"
|
||||
ON public.photo_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM content_submissions cs
|
||||
WHERE cs.id = submission_id
|
||||
AND cs.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Photo Submissions: Moderators can insert any submissions
|
||||
CREATE POLICY "photo_submissions_insert_moderators"
|
||||
ON public.photo_submissions
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
is_moderator(auth.uid())
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 2: Fix SQL Column Names (ride_type → category)
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_trace_id TEXT DEFAULT NULL,
|
||||
p_parent_span_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start_time TIMESTAMPTZ;
|
||||
v_result JSONB;
|
||||
v_item RECORD;
|
||||
v_item_data JSONB;
|
||||
v_resolved_refs JSONB;
|
||||
v_entity_id UUID;
|
||||
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||
v_final_status TEXT;
|
||||
v_all_approved BOOLEAN := TRUE;
|
||||
v_some_approved BOOLEAN := FALSE;
|
||||
v_items_processed INTEGER := 0;
|
||||
v_span_id TEXT;
|
||||
BEGIN
|
||||
v_start_time := clock_timestamp();
|
||||
v_span_id := gen_random_uuid()::text;
|
||||
|
||||
-- Log span start with trace context
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
v_span_id,
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
EXTRACT(EPOCH FROM v_start_time) * 1000,
|
||||
p_submission_id,
|
||||
array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Starting atomic approval transaction for submission %',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 1: Set session variables (transaction-scoped with is_local=true)
|
||||
-- ========================================================================
|
||||
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 2: Validate submission ownership and lock status
|
||||
-- ========================================================================
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM content_submissions
|
||||
WHERE id = p_submission_id
|
||||
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
|
||||
AND status IN ('pending', 'partially_approved')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 3: Process each item sequentially within this transaction
|
||||
-- ========================================================================
|
||||
FOR v_item IN
|
||||
SELECT
|
||||
si.*,
|
||||
ps.name as park_name,
|
||||
ps.slug as park_slug,
|
||||
ps.description as park_description,
|
||||
ps.park_type,
|
||||
ps.status as park_status,
|
||||
ps.location_id,
|
||||
ps.operator_id,
|
||||
ps.property_owner_id,
|
||||
ps.opening_date as park_opening_date,
|
||||
ps.closing_date as park_closing_date,
|
||||
ps.opening_date_precision as park_opening_date_precision,
|
||||
ps.closing_date_precision as park_closing_date_precision,
|
||||
ps.website_url as park_website_url,
|
||||
ps.phone as park_phone,
|
||||
ps.email as park_email,
|
||||
ps.banner_image_url as park_banner_image_url,
|
||||
ps.banner_image_id as park_banner_image_id,
|
||||
ps.card_image_url as park_card_image_url,
|
||||
ps.card_image_id as park_card_image_id,
|
||||
rs.name as ride_name,
|
||||
rs.slug as ride_slug,
|
||||
rs.park_id as ride_park_id,
|
||||
rs.category as ride_category,
|
||||
rs.status as ride_status,
|
||||
rs.manufacturer_id,
|
||||
rs.ride_model_id,
|
||||
rs.opening_date as ride_opening_date,
|
||||
rs.closing_date as ride_closing_date,
|
||||
rs.opening_date_precision as ride_opening_date_precision,
|
||||
rs.closing_date_precision as ride_closing_date_precision,
|
||||
rs.description as ride_description,
|
||||
rs.banner_image_url as ride_banner_image_url,
|
||||
rs.banner_image_id as ride_banner_image_id,
|
||||
rs.card_image_url as ride_card_image_url,
|
||||
rs.card_image_id as ride_card_image_id,
|
||||
cs.name as company_name,
|
||||
cs.slug as company_slug,
|
||||
cs.description as company_description,
|
||||
cs.website_url as company_website_url,
|
||||
cs.founded_year,
|
||||
cs.banner_image_url as company_banner_image_url,
|
||||
cs.banner_image_id as company_banner_image_id,
|
||||
cs.card_image_url as company_card_image_url,
|
||||
cs.card_image_id as company_card_image_id,
|
||||
rms.name as ride_model_name,
|
||||
rms.slug as ride_model_slug,
|
||||
rms.manufacturer_id as ride_model_manufacturer_id,
|
||||
rms.category as ride_model_category,
|
||||
rms.description as ride_model_description,
|
||||
rms.banner_image_url as ride_model_banner_image_url,
|
||||
rms.banner_image_id as ride_model_banner_image_id,
|
||||
rms.card_image_url as ride_model_card_image_url,
|
||||
rms.card_image_id as ride_model_card_image_id,
|
||||
phs.entity_id as photo_entity_id,
|
||||
phs.entity_type as photo_entity_type,
|
||||
phs.title as photo_title
|
||||
FROM submission_items si
|
||||
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
|
||||
WHERE si.id = ANY(p_item_ids)
|
||||
ORDER BY si.order_index, si.created_at
|
||||
LOOP
|
||||
BEGIN
|
||||
v_items_processed := v_items_processed + 1;
|
||||
|
||||
-- Log item processing span event
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||
p_trace_id,
|
||||
v_span_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Build item data based on entity type
|
||||
IF v_item.item_type = 'park' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.park_name,
|
||||
'slug', v_item.park_slug,
|
||||
'description', v_item.park_description,
|
||||
'park_type', v_item.park_type,
|
||||
'status', v_item.park_status,
|
||||
'location_id', v_item.location_id,
|
||||
'operator_id', v_item.operator_id,
|
||||
'property_owner_id', v_item.property_owner_id,
|
||||
'opening_date', v_item.park_opening_date,
|
||||
'closing_date', v_item.park_closing_date,
|
||||
'opening_date_precision', v_item.park_opening_date_precision,
|
||||
'closing_date_precision', v_item.park_closing_date_precision,
|
||||
'website_url', v_item.park_website_url,
|
||||
'phone', v_item.park_phone,
|
||||
'email', v_item.park_email,
|
||||
'banner_image_url', v_item.park_banner_image_url,
|
||||
'banner_image_id', v_item.park_banner_image_id,
|
||||
'card_image_url', v_item.park_card_image_url,
|
||||
'card_image_id', v_item.park_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_name,
|
||||
'slug', v_item.ride_slug,
|
||||
'park_id', v_item.ride_park_id,
|
||||
'category', v_item.ride_category,
|
||||
'status', v_item.ride_status,
|
||||
'manufacturer_id', v_item.manufacturer_id,
|
||||
'ride_model_id', v_item.ride_model_id,
|
||||
'opening_date', v_item.ride_opening_date,
|
||||
'closing_date', v_item.ride_closing_date,
|
||||
'opening_date_precision', v_item.ride_opening_date_precision,
|
||||
'closing_date_precision', v_item.ride_closing_date_precision,
|
||||
'description', v_item.ride_description,
|
||||
'banner_image_url', v_item.ride_banner_image_url,
|
||||
'banner_image_id', v_item.ride_banner_image_id,
|
||||
'card_image_url', v_item.ride_card_image_url,
|
||||
'card_image_id', v_item.ride_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'company' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.company_name,
|
||||
'slug', v_item.company_slug,
|
||||
'description', v_item.company_description,
|
||||
'website_url', v_item.company_website_url,
|
||||
'founded_year', v_item.founded_year,
|
||||
'banner_image_url', v_item.company_banner_image_url,
|
||||
'banner_image_id', v_item.company_banner_image_id,
|
||||
'card_image_url', v_item.company_card_image_url,
|
||||
'card_image_id', v_item.company_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride_model' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_model_name,
|
||||
'slug', v_item.ride_model_slug,
|
||||
'manufacturer_id', v_item.ride_model_manufacturer_id,
|
||||
'category', v_item.ride_model_category,
|
||||
'description', v_item.ride_model_description,
|
||||
'banner_image_url', v_item.ride_model_banner_image_url,
|
||||
'banner_image_id', v_item.ride_model_banner_image_id,
|
||||
'card_image_url', v_item.ride_model_card_image_url,
|
||||
'card_image_id', v_item.ride_model_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'photo' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'entity_id', v_item.photo_entity_id,
|
||||
'entity_type', v_item.photo_entity_type,
|
||||
'title', v_item.photo_title
|
||||
);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown item type: %', v_item.item_type;
|
||||
END IF;
|
||||
|
||||
-- Resolve temporary references
|
||||
v_resolved_refs := resolve_temp_references(v_item_data, p_submission_id);
|
||||
|
||||
-- Perform the action
|
||||
IF v_item.action_type = 'create' THEN
|
||||
v_entity_id := perform_create(v_item.item_type, v_resolved_refs, p_submitter_id, p_submission_id);
|
||||
ELSIF v_item.action_type = 'update' THEN
|
||||
IF v_item.entity_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Update action requires entity_id';
|
||||
END IF;
|
||||
PERFORM perform_update(v_item.item_type, v_item.entity_id, v_resolved_refs, p_submitter_id, p_submission_id);
|
||||
v_entity_id := v_item.entity_id;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown action type: %', v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Update submission_item with approved entity
|
||||
UPDATE submission_items
|
||||
SET approved_entity_id = v_entity_id,
|
||||
approved_at = now(),
|
||||
status = 'approved'
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Track approval results
|
||||
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'status', 'approved',
|
||||
'entity_id', v_entity_id
|
||||
));
|
||||
|
||||
v_some_approved := TRUE;
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- Log the error
|
||||
RAISE WARNING 'Failed to process item %: % - %', v_item.id, SQLERRM, SQLSTATE;
|
||||
|
||||
-- Track failure
|
||||
v_approval_results := array_append(v_approval_results, jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'status', 'failed',
|
||||
'error', SQLERRM
|
||||
));
|
||||
|
||||
v_all_approved := FALSE;
|
||||
|
||||
-- Re-raise to rollback transaction
|
||||
RAISE;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 4: Update submission status
|
||||
-- ========================================================================
|
||||
IF v_all_approved THEN
|
||||
v_final_status := 'approved';
|
||||
ELSIF v_some_approved THEN
|
||||
v_final_status := 'partially_approved';
|
||||
ELSE
|
||||
v_final_status := 'rejected';
|
||||
END IF;
|
||||
|
||||
UPDATE content_submissions
|
||||
SET status = v_final_status,
|
||||
resolved_at = CASE WHEN v_all_approved THEN now() ELSE NULL END,
|
||||
reviewer_id = p_moderator_id,
|
||||
reviewed_at = now()
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- Log span end
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "endTime": %, "attributes": {"items_processed": %, "final_status": "%"}}',
|
||||
v_span_id,
|
||||
p_trace_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_items_processed,
|
||||
v_final_status;
|
||||
END IF;
|
||||
|
||||
-- Return result
|
||||
RETURN jsonb_build_object(
|
||||
'success', v_all_approved,
|
||||
'status', v_final_status,
|
||||
'items_processed', v_items_processed,
|
||||
'results', v_approval_results,
|
||||
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
474
supabase/migrations/20251111000000_fix_approval_pipeline.sql
Normal file
474
supabase/migrations/20251111000000_fix_approval_pipeline.sql
Normal file
@@ -0,0 +1,474 @@
|
||||
-- ============================================================================
|
||||
-- CRITICAL FIX: Restore complete approval pipeline with tracing
|
||||
-- ============================================================================
|
||||
-- This fixes the broken RPC that deleted all non-park entity handling
|
||||
-- and restores full functionality for all entity types
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS process_approval_transaction(UUID, UUID[], UUID, UUID, TEXT, TEXT, TEXT);
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||
p_submission_id UUID,
|
||||
p_item_ids UUID[],
|
||||
p_moderator_id UUID,
|
||||
p_submitter_id UUID,
|
||||
p_request_id TEXT DEFAULT NULL,
|
||||
p_trace_id TEXT DEFAULT NULL,
|
||||
p_parent_span_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start_time TIMESTAMPTZ;
|
||||
v_result JSONB;
|
||||
v_item RECORD;
|
||||
v_item_data JSONB;
|
||||
v_resolved_refs JSONB;
|
||||
v_entity_id UUID;
|
||||
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||
v_final_status TEXT;
|
||||
v_all_approved BOOLEAN := TRUE;
|
||||
v_some_approved BOOLEAN := FALSE;
|
||||
v_items_processed INTEGER := 0;
|
||||
v_span_id TEXT;
|
||||
BEGIN
|
||||
v_start_time := clock_timestamp();
|
||||
v_span_id := gen_random_uuid()::text;
|
||||
|
||||
-- Log span start with trace context
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN: {"spanId": "%", "traceId": "%", "parentSpanId": "%", "name": "process_approval_transaction_rpc", "kind": "INTERNAL", "startTime": %, "attributes": {"submission.id": "%", "item_count": %}}',
|
||||
v_span_id,
|
||||
p_trace_id,
|
||||
p_parent_span_id,
|
||||
EXTRACT(EPOCH FROM v_start_time) * 1000,
|
||||
p_submission_id,
|
||||
array_length(p_item_ids, 1);
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Starting atomic approval transaction for submission %',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 1: Set session variables (transaction-scoped with is_local=true)
|
||||
-- ========================================================================
|
||||
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 2: Validate submission ownership and lock status
|
||||
-- ========================================================================
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM content_submissions
|
||||
WHERE id = p_submission_id
|
||||
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
|
||||
AND status IN ('pending', 'partially_approved')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 3: Process each item sequentially within this transaction
|
||||
-- ========================================================================
|
||||
FOR v_item IN
|
||||
SELECT
|
||||
si.*,
|
||||
ps.name as park_name,
|
||||
ps.slug as park_slug,
|
||||
ps.description as park_description,
|
||||
ps.park_type,
|
||||
ps.status as park_status,
|
||||
ps.location_id,
|
||||
ps.operator_id,
|
||||
ps.property_owner_id,
|
||||
ps.opening_date as park_opening_date,
|
||||
ps.closing_date as park_closing_date,
|
||||
ps.opening_date_precision as park_opening_date_precision,
|
||||
ps.closing_date_precision as park_closing_date_precision,
|
||||
ps.website_url as park_website_url,
|
||||
ps.phone as park_phone,
|
||||
ps.email as park_email,
|
||||
ps.banner_image_url as park_banner_image_url,
|
||||
ps.banner_image_id as park_banner_image_id,
|
||||
ps.card_image_url as park_card_image_url,
|
||||
ps.card_image_id as park_card_image_id,
|
||||
rs.name as ride_name,
|
||||
rs.slug as ride_slug,
|
||||
rs.park_id as ride_park_id,
|
||||
rs.ride_type,
|
||||
rs.status as ride_status,
|
||||
rs.manufacturer_id,
|
||||
rs.ride_model_id,
|
||||
rs.opening_date as ride_opening_date,
|
||||
rs.closing_date as ride_closing_date,
|
||||
rs.opening_date_precision as ride_opening_date_precision,
|
||||
rs.closing_date_precision as ride_closing_date_precision,
|
||||
rs.description as ride_description,
|
||||
rs.banner_image_url as ride_banner_image_url,
|
||||
rs.banner_image_id as ride_banner_image_id,
|
||||
rs.card_image_url as ride_card_image_url,
|
||||
rs.card_image_id as ride_card_image_id,
|
||||
cs.name as company_name,
|
||||
cs.slug as company_slug,
|
||||
cs.description as company_description,
|
||||
cs.website_url as company_website_url,
|
||||
cs.founded_year,
|
||||
cs.banner_image_url as company_banner_image_url,
|
||||
cs.banner_image_id as company_banner_image_id,
|
||||
cs.card_image_url as company_card_image_url,
|
||||
cs.card_image_id as company_card_image_id,
|
||||
rms.name as ride_model_name,
|
||||
rms.slug as ride_model_slug,
|
||||
rms.manufacturer_id as ride_model_manufacturer_id,
|
||||
rms.ride_type as ride_model_ride_type,
|
||||
rms.description as ride_model_description,
|
||||
rms.banner_image_url as ride_model_banner_image_url,
|
||||
rms.banner_image_id as ride_model_banner_image_id,
|
||||
rms.card_image_url as ride_model_card_image_url,
|
||||
rms.card_image_id as ride_model_card_image_id,
|
||||
phs.entity_id as photo_entity_id,
|
||||
phs.entity_type as photo_entity_type,
|
||||
phs.title as photo_title
|
||||
FROM submission_items si
|
||||
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||
LEFT JOIN photo_submissions phs ON si.photo_submission_id = phs.id
|
||||
WHERE si.id = ANY(p_item_ids)
|
||||
ORDER BY si.order_index, si.created_at
|
||||
LOOP
|
||||
BEGIN
|
||||
v_items_processed := v_items_processed + 1;
|
||||
|
||||
-- Log item processing span event
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "process_item", "timestamp": %, "attributes": {"item.id": "%", "item.type": "%", "item.action": "%"}}',
|
||||
p_trace_id,
|
||||
v_span_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Build item data based on entity type
|
||||
IF v_item.item_type = 'park' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.park_name,
|
||||
'slug', v_item.park_slug,
|
||||
'description', v_item.park_description,
|
||||
'park_type', v_item.park_type,
|
||||
'status', v_item.park_status,
|
||||
'location_id', v_item.location_id,
|
||||
'operator_id', v_item.operator_id,
|
||||
'property_owner_id', v_item.property_owner_id,
|
||||
'opening_date', v_item.park_opening_date,
|
||||
'closing_date', v_item.park_closing_date,
|
||||
'opening_date_precision', v_item.park_opening_date_precision,
|
||||
'closing_date_precision', v_item.park_closing_date_precision,
|
||||
'website_url', v_item.park_website_url,
|
||||
'phone', v_item.park_phone,
|
||||
'email', v_item.park_email,
|
||||
'banner_image_url', v_item.park_banner_image_url,
|
||||
'banner_image_id', v_item.park_banner_image_id,
|
||||
'card_image_url', v_item.park_card_image_url,
|
||||
'card_image_id', v_item.park_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_name,
|
||||
'slug', v_item.ride_slug,
|
||||
'park_id', v_item.ride_park_id,
|
||||
'ride_type', v_item.ride_type,
|
||||
'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
|
||||
);
|
||||
ELSIF v_item.item_type IN ('manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.company_name,
|
||||
'slug', v_item.company_slug,
|
||||
'description', v_item.company_description,
|
||||
'website_url', v_item.company_website_url,
|
||||
'founded_year', v_item.founded_year,
|
||||
'banner_image_url', v_item.company_banner_image_url,
|
||||
'banner_image_id', v_item.company_banner_image_id,
|
||||
'card_image_url', v_item.company_card_image_url,
|
||||
'card_image_id', v_item.company_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'ride_model' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'name', v_item.ride_model_name,
|
||||
'slug', v_item.ride_model_slug,
|
||||
'manufacturer_id', v_item.ride_model_manufacturer_id,
|
||||
'ride_type', v_item.ride_model_ride_type,
|
||||
'description', v_item.ride_model_description,
|
||||
'banner_image_url', v_item.ride_model_banner_image_url,
|
||||
'banner_image_id', v_item.ride_model_banner_image_id,
|
||||
'card_image_url', v_item.ride_model_card_image_url,
|
||||
'card_image_id', v_item.ride_model_card_image_id
|
||||
);
|
||||
ELSIF v_item.item_type = 'photo' THEN
|
||||
v_item_data := jsonb_build_object(
|
||||
'entity_id', v_item.photo_entity_id,
|
||||
'entity_type', v_item.photo_entity_type,
|
||||
'title', v_item.photo_title,
|
||||
'photo_submission_id', v_item.photo_submission_id
|
||||
);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unsupported item_type: %', v_item.item_type;
|
||||
END IF;
|
||||
|
||||
-- ======================================================================
|
||||
-- Resolve temp refs and update v_item_data with actual entity IDs
|
||||
-- ======================================================================
|
||||
v_resolved_refs := resolve_temp_refs_for_item(v_item.id, p_submission_id);
|
||||
|
||||
IF v_resolved_refs IS NOT NULL AND jsonb_typeof(v_resolved_refs) = 'object' THEN
|
||||
-- Replace NULL foreign keys with resolved entity IDs
|
||||
-- For parks: operator_id, property_owner_id
|
||||
IF v_item.item_type = 'park' THEN
|
||||
IF v_resolved_refs ? 'operator' AND (v_item_data->>'operator_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('operator_id', v_resolved_refs->>'operator');
|
||||
RAISE NOTICE 'Resolved park.operator_id → %', v_resolved_refs->>'operator';
|
||||
END IF;
|
||||
IF v_resolved_refs ? 'property_owner' AND (v_item_data->>'property_owner_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('property_owner_id', v_resolved_refs->>'property_owner');
|
||||
RAISE NOTICE 'Resolved park.property_owner_id → %', v_resolved_refs->>'property_owner';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- For rides: park_id, manufacturer_id, ride_model_id
|
||||
IF v_item.item_type = 'ride' THEN
|
||||
IF v_resolved_refs ? 'park' AND (v_item_data->>'park_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('park_id', v_resolved_refs->>'park');
|
||||
RAISE NOTICE 'Resolved ride.park_id → %', v_resolved_refs->>'park';
|
||||
END IF;
|
||||
IF v_resolved_refs ? 'manufacturer' AND (v_item_data->>'manufacturer_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('manufacturer_id', v_resolved_refs->>'manufacturer');
|
||||
RAISE NOTICE 'Resolved ride.manufacturer_id → %', v_resolved_refs->>'manufacturer';
|
||||
END IF;
|
||||
IF v_resolved_refs ? 'ride_model' AND (v_item_data->>'ride_model_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('ride_model_id', v_resolved_refs->>'ride_model');
|
||||
RAISE NOTICE 'Resolved ride.ride_model_id → %', v_resolved_refs->>'ride_model';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- For ride_models: manufacturer_id
|
||||
IF v_item.item_type = 'ride_model' THEN
|
||||
IF v_resolved_refs ? 'manufacturer' AND (v_item_data->>'manufacturer_id') IS NULL THEN
|
||||
v_item_data := v_item_data || jsonb_build_object('manufacturer_id', v_resolved_refs->>'manufacturer');
|
||||
RAISE NOTICE 'Resolved ride_model.manufacturer_id → %', v_resolved_refs->>'manufacturer';
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Execute action based on action_type and item_type
|
||||
IF v_item.action_type = 'create' THEN
|
||||
IF v_item.item_type = 'photo' THEN
|
||||
-- Insert all photo_submission_items as photos atomically
|
||||
INSERT INTO photos (
|
||||
entity_id, entity_type, cloudflare_image_id, cloudflare_image_url,
|
||||
title, caption, date_taken, date_taken_precision, order_index,
|
||||
submission_id, submitted_by, approved_by, approved_at
|
||||
)
|
||||
SELECT
|
||||
(v_item_data->>'entity_id')::UUID,
|
||||
v_item_data->>'entity_type',
|
||||
psi.cloudflare_image_id,
|
||||
psi.cloudflare_image_url,
|
||||
psi.title,
|
||||
psi.caption,
|
||||
psi.date_taken,
|
||||
psi.date_taken_precision,
|
||||
psi.order_index,
|
||||
p_submission_id,
|
||||
p_submitter_id,
|
||||
p_moderator_id,
|
||||
NOW()
|
||||
FROM photo_submission_items psi
|
||||
WHERE psi.photo_submission_id = (v_item_data->>'photo_submission_id')::UUID;
|
||||
|
||||
-- Return the photo_submission entity_id as v_entity_id
|
||||
v_entity_id := (v_item_data->>'entity_id')::UUID;
|
||||
|
||||
RAISE NOTICE '[%] Created % photos for entity % (type=%)',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
(SELECT COUNT(*) FROM photo_submission_items WHERE photo_submission_id = (v_item_data->>'photo_submission_id')::UUID),
|
||||
v_entity_id,
|
||||
v_item_data->>'entity_type';
|
||||
ELSE
|
||||
-- Standard entity creation - FIXED: Pass v_item_data instead of v_item.id
|
||||
v_entity_id := create_entity_from_submission(
|
||||
v_item.item_type,
|
||||
v_item_data,
|
||||
p_submitter_id
|
||||
);
|
||||
END IF;
|
||||
ELSIF v_item.action_type = 'update' THEN
|
||||
v_entity_id := update_entity_from_submission(
|
||||
v_item.item_type,
|
||||
v_item_data,
|
||||
v_item.target_entity_id,
|
||||
p_submitter_id
|
||||
);
|
||||
ELSIF v_item.action_type = 'delete' THEN
|
||||
PERFORM delete_entity_from_submission(
|
||||
v_item.item_type,
|
||||
v_item.target_entity_id,
|
||||
p_submitter_id
|
||||
);
|
||||
v_entity_id := v_item.target_entity_id;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unknown action_type: %', v_item.action_type;
|
||||
END IF;
|
||||
|
||||
-- Update submission_item to approved status
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'approved',
|
||||
approved_entity_id = v_entity_id,
|
||||
updated_at = NOW()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Track success
|
||||
v_approval_results := array_append(
|
||||
v_approval_results,
|
||||
jsonb_build_object(
|
||||
'itemId', v_item.id,
|
||||
'entityId', v_entity_id,
|
||||
'itemType', v_item.item_type,
|
||||
'actionType', v_item.action_type,
|
||||
'success', true
|
||||
)
|
||||
);
|
||||
|
||||
v_some_approved := TRUE;
|
||||
|
||||
RAISE NOTICE '[%] Approved item % (type=%s, action=%s, entityId=%s)',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_item.id,
|
||||
v_item.item_type,
|
||||
v_item.action_type,
|
||||
v_entity_id;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Log error but continue processing remaining items
|
||||
RAISE WARNING '[%] Item % failed: % (SQLSTATE: %)',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_item.id,
|
||||
SQLERRM,
|
||||
SQLSTATE;
|
||||
|
||||
-- Update submission_item to rejected status
|
||||
UPDATE submission_items
|
||||
SET
|
||||
status = 'rejected',
|
||||
rejection_reason = SQLERRM,
|
||||
updated_at = NOW()
|
||||
WHERE id = v_item.id;
|
||||
|
||||
-- Track failure
|
||||
v_approval_results := array_append(
|
||||
v_approval_results,
|
||||
jsonb_build_object(
|
||||
'itemId', v_item.id,
|
||||
'itemType', v_item.item_type,
|
||||
'actionType', v_item.action_type,
|
||||
'success', false,
|
||||
'error', SQLERRM
|
||||
)
|
||||
);
|
||||
|
||||
v_all_approved := FALSE;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 4: Determine final submission status
|
||||
-- ========================================================================
|
||||
v_final_status := CASE
|
||||
WHEN v_all_approved THEN 'approved'
|
||||
WHEN v_some_approved THEN 'partially_approved'
|
||||
ELSE 'rejected'
|
||||
END;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 5: Update submission status
|
||||
-- ========================================================================
|
||||
UPDATE content_submissions
|
||||
SET
|
||||
status = v_final_status,
|
||||
reviewer_id = p_moderator_id,
|
||||
reviewed_at = NOW(),
|
||||
assigned_to = NULL,
|
||||
locked_until = NULL
|
||||
WHERE id = p_submission_id;
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 6: Log metrics
|
||||
-- ========================================================================
|
||||
INSERT INTO approval_transaction_metrics (
|
||||
submission_id,
|
||||
moderator_id,
|
||||
submitter_id,
|
||||
items_count,
|
||||
duration_ms,
|
||||
success,
|
||||
request_id
|
||||
) VALUES (
|
||||
p_submission_id,
|
||||
p_moderator_id,
|
||||
p_submitter_id,
|
||||
v_items_processed,
|
||||
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000,
|
||||
v_all_approved,
|
||||
p_request_id
|
||||
);
|
||||
|
||||
-- ========================================================================
|
||||
-- STEP 7: Build result
|
||||
-- ========================================================================
|
||||
v_result := jsonb_build_object(
|
||||
'success', v_all_approved,
|
||||
'submissionId', p_submission_id,
|
||||
'finalStatus', v_final_status,
|
||||
'itemsProcessed', v_items_processed,
|
||||
'results', v_approval_results,
|
||||
'durationMs', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
|
||||
);
|
||||
|
||||
-- Log span completion
|
||||
IF p_trace_id IS NOT NULL THEN
|
||||
RAISE NOTICE 'SPAN_EVENT: {"traceId": "%", "parentSpanId": "%", "name": "transaction_complete", "timestamp": %, "attributes": {"items_processed": %, "final_status": "%"}}',
|
||||
p_trace_id,
|
||||
v_span_id,
|
||||
EXTRACT(EPOCH FROM clock_timestamp()) * 1000,
|
||||
v_items_processed,
|
||||
v_final_status;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[%] Transaction complete: % items processed, status=%s',
|
||||
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||
v_items_processed,
|
||||
v_final_status;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
245
tests/integration/README.md
Normal file
245
tests/integration/README.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Integration Tests
|
||||
|
||||
This directory contains integration tests for the ThrillWiki submission pipeline and data integrity.
|
||||
|
||||
## Schema Validation Tests
|
||||
|
||||
**File**: `schema-validation.test.ts`
|
||||
|
||||
### Purpose
|
||||
|
||||
Automated tests that validate schema consistency across the entire submission pipeline:
|
||||
|
||||
- **Submission Tables**: Ensures submission tables match their corresponding main entity tables
|
||||
- **Version Tables**: Validates version tables have all main table fields plus version metadata
|
||||
- **Critical Fields**: Checks for known problematic fields (e.g., `ride_type` vs `category`)
|
||||
- **Function Alignment**: Verifies critical database functions exist and are accessible
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The submission pipeline depends on exact schema alignment between:
|
||||
1. Main entity tables (`parks`, `rides`, `companies`, `ride_models`)
|
||||
2. Submission tables (`park_submissions`, `ride_submissions`, etc.)
|
||||
3. Version tables (`park_versions`, `ride_versions`, etc.)
|
||||
|
||||
**Without these tests**, schema mismatches can cause:
|
||||
- ❌ Approval failures with cryptic "column does not exist" errors
|
||||
- ❌ Data loss when fields are missing from submission tables
|
||||
- ❌ Version history corruption when fields don't match
|
||||
- ❌ Production incidents that are difficult to debug
|
||||
|
||||
**With these tests**, we catch issues:
|
||||
- ✅ During development, before they reach production
|
||||
- ✅ In CI/CD, preventing bad migrations from deploying
|
||||
- ✅ Immediately after schema changes, with clear error messages
|
||||
|
||||
### Test Categories
|
||||
|
||||
#### 1. Entity Table Validation
|
||||
Compares main entity tables with their submission counterparts:
|
||||
```typescript
|
||||
parks ↔ park_submissions
|
||||
rides ↔ ride_submissions
|
||||
companies ↔ company_submissions
|
||||
ride_models ↔ ride_model_submissions
|
||||
```
|
||||
|
||||
**Checks**:
|
||||
- All fields from main table exist in submission table (except excluded metadata)
|
||||
- Data types match exactly
|
||||
- Required fields are marked NOT NULL in both
|
||||
|
||||
#### 2. Version Table Validation
|
||||
Ensures version tables have complete field coverage:
|
||||
```typescript
|
||||
parks → park_versions
|
||||
rides → ride_versions
|
||||
companies → company_versions
|
||||
ride_models → ride_model_versions
|
||||
```
|
||||
|
||||
**Checks**:
|
||||
- All main table fields exist (accounting for known name variations)
|
||||
- Version metadata fields are present (`version_id`, `version_number`, etc.)
|
||||
- Change tracking fields are properly defined
|
||||
|
||||
#### 3. Critical Field Validation
|
||||
Tests specific known problem areas:
|
||||
|
||||
**Critical Test Cases**:
|
||||
- ✅ `rides` table does NOT have `ride_type` (prevents "column does not exist" error)
|
||||
- ✅ `rides` table DOES have `category` as NOT NULL
|
||||
- ✅ `ride_models` table has BOTH `category` and `ride_type`
|
||||
- ✅ All entities have required base fields (`id`, `name`, `slug`, etc.)
|
||||
- ✅ All submission tables have `submission_id` foreign key
|
||||
|
||||
#### 4. Function Alignment
|
||||
Validates critical database functions:
|
||||
- `create_entity_from_submission`
|
||||
- `update_entity_from_submission`
|
||||
- `process_approval_transaction`
|
||||
|
||||
#### 5. Field Name Variations
|
||||
Documents and validates known column name differences:
|
||||
```typescript
|
||||
ride_versions.height_requirement_cm ↔ rides.height_requirement
|
||||
ride_versions.gforce_max ↔ rides.max_g_force
|
||||
ride_versions.inversions_count ↔ rides.inversions
|
||||
ride_versions.height_meters ↔ rides.max_height_meters
|
||||
ride_versions.drop_meters ↔ rides.drop_height_meters
|
||||
```
|
||||
|
||||
### Running the Tests
|
||||
|
||||
**Run all schema validation tests:**
|
||||
```bash
|
||||
npm run test:schema
|
||||
```
|
||||
|
||||
**Run specific test suite:**
|
||||
```bash
|
||||
npx playwright test schema-validation --grep "Entity Tables"
|
||||
```
|
||||
|
||||
**Run in UI mode for debugging:**
|
||||
```bash
|
||||
npx playwright test schema-validation --ui
|
||||
```
|
||||
|
||||
**Generate detailed report:**
|
||||
```bash
|
||||
npx playwright test schema-validation --reporter=html
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
These tests require:
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` environment variable
|
||||
- Access to the Supabase project database
|
||||
- Playwright test runner
|
||||
|
||||
**Example `.env.test`:**
|
||||
```env
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
|
||||
```
|
||||
|
||||
### Expected Output
|
||||
|
||||
**✅ All passing (healthy schema):**
|
||||
```
|
||||
✓ parks: submission table matches main table schema (245ms)
|
||||
✓ rides: submission table matches main table schema (198ms)
|
||||
✓ companies: submission table matches main table schema (187ms)
|
||||
✓ ride_models: submission table matches main table schema (203ms)
|
||||
✓ park_versions: has all main table fields plus version metadata (256ms)
|
||||
✓ ride_versions: has all main table fields plus version metadata (234ms)
|
||||
✓ rides table does NOT have ride_type column (145ms)
|
||||
✓ rides table DOES have category column (NOT NULL) (152ms)
|
||||
```
|
||||
|
||||
**❌ Failure example (schema mismatch):**
|
||||
```
|
||||
✕ rides: submission table matches main table schema (203ms)
|
||||
|
||||
Error: ride_submissions is missing fields: category
|
||||
|
||||
Expected: 0
|
||||
Received: 1
|
||||
```
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
Add to your CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
- name: Run Schema Validation Tests
|
||||
run: npm run test:schema
|
||||
env:
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||
```
|
||||
|
||||
This prevents schema mismatches from reaching production.
|
||||
|
||||
### When to Run
|
||||
|
||||
**Always run these tests:**
|
||||
- ✅ After any database migration
|
||||
- ✅ Before deploying submission pipeline changes
|
||||
- ✅ After modifying entity schemas
|
||||
- ✅ When adding new entity types
|
||||
- ✅ In CI/CD for every pull request
|
||||
|
||||
**Especially critical after:**
|
||||
- Adding/removing columns from entity tables
|
||||
- Modifying data types
|
||||
- Changing NOT NULL constraints
|
||||
- Updating database functions
|
||||
|
||||
### Maintenance
|
||||
|
||||
**When adding new entity types:**
|
||||
1. Add validation tests for the new entity
|
||||
2. Add tests for submission table
|
||||
3. Add tests for version table (if applicable)
|
||||
4. Update this README
|
||||
|
||||
**When schema changes are intentional:**
|
||||
1. Review failing tests carefully
|
||||
2. Update `EXCLUDED_FIELDS` or `VERSION_METADATA_FIELDS` if needed
|
||||
3. Document any new field name variations in `normalizeColumnName()`
|
||||
4. Update `docs/submission-pipeline/SCHEMA_REFERENCE.md`
|
||||
|
||||
### Debugging Failed Tests
|
||||
|
||||
**"Missing fields" error:**
|
||||
1. Check if field was recently added to main table
|
||||
2. Verify migration added it to submission table too
|
||||
3. Run migration to add missing field
|
||||
4. Re-run tests
|
||||
|
||||
**"Type mismatch" error:**
|
||||
1. Compare data types in both tables
|
||||
2. Check for accidental type change in migration
|
||||
3. Fix type inconsistency
|
||||
4. Re-run tests
|
||||
|
||||
**"Column does not exist" in production:**
|
||||
1. Run schema validation tests immediately
|
||||
2. Identify which table is missing the field
|
||||
3. Create emergency migration to add field
|
||||
4. Deploy with high priority
|
||||
|
||||
### Related Documentation
|
||||
|
||||
- [Schema Reference](../../docs/submission-pipeline/SCHEMA_REFERENCE.md) - Complete field mappings
|
||||
- [Submission Pipeline](../../docs/submission-pipeline/README.md) - Pipeline overview
|
||||
- [Versioning System](../../docs/versioning/README.md) - Version table details
|
||||
- [Moderation Workflow](../../docs/moderation/README.md) - Approval process
|
||||
|
||||
---
|
||||
|
||||
## Other Integration Tests
|
||||
|
||||
### Moderation Security Tests
|
||||
|
||||
**File**: `moderation-security.test.ts`
|
||||
|
||||
Tests role validation, lock enforcement, and rate limiting in the moderation system.
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
npx playwright test moderation-security
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new integration tests:
|
||||
1. Follow existing test structure
|
||||
2. Use descriptive test names
|
||||
3. Add comments explaining what's being tested
|
||||
4. Update this README
|
||||
5. Ensure tests are idempotent (can run multiple times)
|
||||
6. Clean up test data after completion
|
||||
545
tests/integration/schema-validation.test.ts
Normal file
545
tests/integration/schema-validation.test.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* Schema Validation Tests
|
||||
*
|
||||
* These tests validate that submission tables, version tables, and main entity tables
|
||||
* have consistent schemas to prevent field mismatches during the approval pipeline.
|
||||
*
|
||||
* Critical validations:
|
||||
* 1. Submission tables must have all fields from main tables (except auto-generated)
|
||||
* 2. Version tables must have all fields from main tables plus version metadata
|
||||
* 3. Critical functions must reference correct column names
|
||||
* 4. Required NOT NULL fields must be present in all tables
|
||||
*/
|
||||
|
||||
const supabase = createClient(
|
||||
'https://ydvtmnrszybqnbcqbdcy.supabase.co',
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY || ''
|
||||
);
|
||||
|
||||
interface ColumnDefinition {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null;
|
||||
}
|
||||
|
||||
interface TableSchema {
|
||||
[columnName: string]: ColumnDefinition;
|
||||
}
|
||||
|
||||
// Fields that are expected to be different or missing in submission tables
|
||||
const EXCLUDED_FIELDS = [
|
||||
'id', // Submission tables have their own ID
|
||||
'created_at', // Managed differently in submissions
|
||||
'updated_at', // Managed differently in submissions
|
||||
'view_count_all', // Calculated fields not in submissions
|
||||
'view_count_30d',
|
||||
'view_count_7d',
|
||||
'average_rating',
|
||||
'review_count',
|
||||
'installations_count', // Only for ride_models
|
||||
'is_test_data', // Test data flag
|
||||
];
|
||||
|
||||
// Version-specific metadata fields (expected to be extra in version tables)
|
||||
const VERSION_METADATA_FIELDS = [
|
||||
'version_id',
|
||||
'version_number',
|
||||
'change_type',
|
||||
'change_reason',
|
||||
'is_current',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'submission_id',
|
||||
'is_test_data',
|
||||
];
|
||||
|
||||
async function getTableSchema(tableName: string): Promise<TableSchema> {
|
||||
const { data, error } = await supabase
|
||||
.from('information_schema.columns' as any)
|
||||
.select('column_name, data_type, is_nullable, column_default')
|
||||
.eq('table_schema', 'public')
|
||||
.eq('table_name', tableName);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const schema: TableSchema = {};
|
||||
data?.forEach((col: any) => {
|
||||
schema[col.column_name] = col;
|
||||
});
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function normalizeColumnName(name: string): string {
|
||||
// Handle known version table variations
|
||||
const mapping: { [key: string]: string } = {
|
||||
'height_requirement_cm': 'height_requirement',
|
||||
'gforce_max': 'max_g_force',
|
||||
'inversions_count': 'inversions',
|
||||
'height_meters': 'max_height_meters',
|
||||
'drop_meters': 'drop_height_meters',
|
||||
};
|
||||
|
||||
return mapping[name] || name;
|
||||
}
|
||||
|
||||
test.describe('Schema Validation - Entity Tables', () => {
|
||||
test('parks: submission table matches main table schema', async () => {
|
||||
const mainSchema = await getTableSchema('parks');
|
||||
const submissionSchema = await getTableSchema('park_submissions');
|
||||
|
||||
const mismatches: string[] = [];
|
||||
const missingFields: string[] = [];
|
||||
|
||||
// Check each field in main table exists in submission table
|
||||
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
if (!submissionSchema[fieldName]) {
|
||||
missingFields.push(fieldName);
|
||||
} else {
|
||||
// Check data type matches
|
||||
const mainType = fieldDef.data_type;
|
||||
const submissionType = submissionSchema[fieldName].data_type;
|
||||
|
||||
if (mainType !== submissionType) {
|
||||
mismatches.push(
|
||||
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`park_submissions is missing fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(mismatches,
|
||||
`park_submissions has type mismatches: ${mismatches.join('; ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rides: submission table matches main table schema', async () => {
|
||||
const mainSchema = await getTableSchema('rides');
|
||||
const submissionSchema = await getTableSchema('ride_submissions');
|
||||
|
||||
const mismatches: string[] = [];
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
if (!submissionSchema[fieldName]) {
|
||||
missingFields.push(fieldName);
|
||||
} else {
|
||||
const mainType = fieldDef.data_type;
|
||||
const submissionType = submissionSchema[fieldName].data_type;
|
||||
|
||||
if (mainType !== submissionType) {
|
||||
mismatches.push(
|
||||
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`ride_submissions is missing fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(mismatches,
|
||||
`ride_submissions has type mismatches: ${mismatches.join('; ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('companies: submission table matches main table schema', async () => {
|
||||
const mainSchema = await getTableSchema('companies');
|
||||
const submissionSchema = await getTableSchema('company_submissions');
|
||||
|
||||
const mismatches: string[] = [];
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
if (!submissionSchema[fieldName]) {
|
||||
missingFields.push(fieldName);
|
||||
} else {
|
||||
const mainType = fieldDef.data_type;
|
||||
const submissionType = submissionSchema[fieldName].data_type;
|
||||
|
||||
if (mainType !== submissionType) {
|
||||
mismatches.push(
|
||||
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`company_submissions is missing fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(mismatches,
|
||||
`company_submissions has type mismatches: ${mismatches.join('; ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ride_models: submission table matches main table schema', async () => {
|
||||
const mainSchema = await getTableSchema('ride_models');
|
||||
const submissionSchema = await getTableSchema('ride_model_submissions');
|
||||
|
||||
const mismatches: string[] = [];
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const [fieldName, fieldDef] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
if (!submissionSchema[fieldName]) {
|
||||
missingFields.push(fieldName);
|
||||
} else {
|
||||
const mainType = fieldDef.data_type;
|
||||
const submissionType = submissionSchema[fieldName].data_type;
|
||||
|
||||
if (mainType !== submissionType) {
|
||||
mismatches.push(
|
||||
`${fieldName}: main=${mainType}, submission=${submissionType}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`ride_model_submissions is missing fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(mismatches,
|
||||
`ride_model_submissions has type mismatches: ${mismatches.join('; ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schema Validation - Version Tables', () => {
|
||||
test('park_versions: has all main table fields plus version metadata', async () => {
|
||||
const mainSchema = await getTableSchema('parks');
|
||||
const versionSchema = await getTableSchema('park_versions');
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
// Check all main table fields exist in version table
|
||||
for (const [fieldName] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
const normalizedName = normalizeColumnName(fieldName);
|
||||
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
||||
missingFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
// Check all version metadata fields exist
|
||||
const missingMetadata: string[] = [];
|
||||
for (const metaField of VERSION_METADATA_FIELDS) {
|
||||
if (!versionSchema[metaField]) {
|
||||
missingMetadata.push(metaField);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`park_versions is missing main table fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(missingMetadata,
|
||||
`park_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ride_versions: has all main table fields plus version metadata', async () => {
|
||||
const mainSchema = await getTableSchema('rides');
|
||||
const versionSchema = await getTableSchema('ride_versions');
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const [fieldName] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
const normalizedName = normalizeColumnName(fieldName);
|
||||
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
||||
missingFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
const missingMetadata: string[] = [];
|
||||
for (const metaField of VERSION_METADATA_FIELDS) {
|
||||
if (!versionSchema[metaField]) {
|
||||
missingMetadata.push(metaField);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`ride_versions is missing main table fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(missingMetadata,
|
||||
`ride_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('company_versions: has all main table fields plus version metadata', async () => {
|
||||
const mainSchema = await getTableSchema('companies');
|
||||
const versionSchema = await getTableSchema('company_versions');
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const [fieldName] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
const normalizedName = normalizeColumnName(fieldName);
|
||||
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
||||
missingFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
const missingMetadata: string[] = [];
|
||||
for (const metaField of VERSION_METADATA_FIELDS) {
|
||||
if (!versionSchema[metaField]) {
|
||||
missingMetadata.push(metaField);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`company_versions is missing main table fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(missingMetadata,
|
||||
`company_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ride_model_versions: has all main table fields plus version metadata', async () => {
|
||||
const mainSchema = await getTableSchema('ride_models');
|
||||
const versionSchema = await getTableSchema('ride_model_versions');
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const [fieldName] of Object.entries(mainSchema)) {
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) continue;
|
||||
|
||||
const normalizedName = normalizeColumnName(fieldName);
|
||||
if (!versionSchema[fieldName] && !versionSchema[normalizedName]) {
|
||||
missingFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
const missingMetadata: string[] = [];
|
||||
for (const metaField of VERSION_METADATA_FIELDS) {
|
||||
if (!versionSchema[metaField]) {
|
||||
missingMetadata.push(metaField);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingFields,
|
||||
`ride_model_versions is missing main table fields: ${missingFields.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
expect(missingMetadata,
|
||||
`ride_model_versions is missing version metadata: ${missingMetadata.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schema Validation - Critical Fields', () => {
|
||||
test('rides table does NOT have ride_type column', async () => {
|
||||
const ridesSchema = await getTableSchema('rides');
|
||||
|
||||
expect(ridesSchema['ride_type']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rides table DOES have category column (NOT NULL)', async () => {
|
||||
const ridesSchema = await getTableSchema('rides');
|
||||
|
||||
expect(ridesSchema['category']).toBeDefined();
|
||||
expect(ridesSchema['category'].is_nullable).toBe('NO');
|
||||
});
|
||||
|
||||
test('ride_models table DOES have both category and ride_type columns', async () => {
|
||||
const rideModelsSchema = await getTableSchema('ride_models');
|
||||
|
||||
expect(rideModelsSchema['category']).toBeDefined();
|
||||
expect(rideModelsSchema['category'].is_nullable).toBe('NO');
|
||||
expect(rideModelsSchema['ride_type']).toBeDefined();
|
||||
});
|
||||
|
||||
test('all entity tables have required base fields', async () => {
|
||||
const requiredFields = ['id', 'name', 'slug', 'created_at', 'updated_at'];
|
||||
const tables = ['parks', 'rides', 'companies', 'ride_models'];
|
||||
|
||||
for (const table of tables) {
|
||||
const schema = await getTableSchema(table);
|
||||
|
||||
for (const field of requiredFields) {
|
||||
expect(schema[field],
|
||||
`${table} is missing required field: ${field}`
|
||||
).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all submission tables have submission_id foreign key', async () => {
|
||||
const submissionTables = [
|
||||
'park_submissions',
|
||||
'ride_submissions',
|
||||
'company_submissions',
|
||||
'ride_model_submissions',
|
||||
'photo_submissions',
|
||||
];
|
||||
|
||||
for (const table of submissionTables) {
|
||||
const schema = await getTableSchema(table);
|
||||
|
||||
expect(schema['submission_id'],
|
||||
`${table} is missing submission_id foreign key`
|
||||
).toBeDefined();
|
||||
expect(schema['submission_id'].is_nullable).toBe('NO');
|
||||
}
|
||||
});
|
||||
|
||||
test('all version tables have version metadata fields', async () => {
|
||||
const versionTables = [
|
||||
'park_versions',
|
||||
'ride_versions',
|
||||
'company_versions',
|
||||
'ride_model_versions',
|
||||
];
|
||||
|
||||
const requiredVersionFields = [
|
||||
'version_id',
|
||||
'version_number',
|
||||
'change_type',
|
||||
'is_current',
|
||||
];
|
||||
|
||||
for (const table of versionTables) {
|
||||
const schema = await getTableSchema(table);
|
||||
|
||||
for (const field of requiredVersionFields) {
|
||||
expect(schema[field],
|
||||
`${table} is missing required version field: ${field}`
|
||||
).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schema Validation - Function Parameter Alignment', () => {
|
||||
test('verify create_entity_from_submission function exists', async () => {
|
||||
const { data, error } = await supabase
|
||||
.rpc('pg_get_functiondef', {
|
||||
funcid: 'create_entity_from_submission'::any
|
||||
} as any)
|
||||
.single();
|
||||
|
||||
// Function should exist (will error if not)
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test('verify update_entity_from_submission function exists', async () => {
|
||||
const { data, error } = await supabase
|
||||
.rpc('pg_get_functiondef', {
|
||||
funcid: 'update_entity_from_submission'::any
|
||||
} as any)
|
||||
.single();
|
||||
|
||||
// Function should exist (will error if not)
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
test('verify process_approval_transaction function exists', async () => {
|
||||
const { data, error } = await supabase.rpc('pg_catalog.pg_function_is_visible', {
|
||||
funcid: 'process_approval_transaction'::any
|
||||
} as any);
|
||||
|
||||
// Function should be visible
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schema Validation - Known Field Name Variations', () => {
|
||||
test('ride_versions uses height_requirement_cm instead of height_requirement', async () => {
|
||||
const versionSchema = await getTableSchema('ride_versions');
|
||||
|
||||
expect(versionSchema['height_requirement_cm']).toBeDefined();
|
||||
expect(versionSchema['height_requirement']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ride_versions uses gforce_max instead of max_g_force', async () => {
|
||||
const versionSchema = await getTableSchema('ride_versions');
|
||||
|
||||
expect(versionSchema['gforce_max']).toBeDefined();
|
||||
expect(versionSchema['max_g_force']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ride_versions uses inversions_count instead of inversions', async () => {
|
||||
const versionSchema = await getTableSchema('ride_versions');
|
||||
|
||||
expect(versionSchema['inversions_count']).toBeDefined();
|
||||
expect(versionSchema['inversions']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ride_versions uses height_meters instead of max_height_meters', async () => {
|
||||
const versionSchema = await getTableSchema('ride_versions');
|
||||
|
||||
expect(versionSchema['height_meters']).toBeDefined();
|
||||
expect(versionSchema['max_height_meters']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('ride_versions uses drop_meters instead of drop_height_meters', async () => {
|
||||
const versionSchema = await getTableSchema('ride_versions');
|
||||
|
||||
expect(versionSchema['drop_meters']).toBeDefined();
|
||||
expect(versionSchema['drop_height_meters']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Schema Validation - Submission Items', () => {
|
||||
test('submission_items has all required foreign key columns', async () => {
|
||||
const schema = await getTableSchema('submission_items');
|
||||
|
||||
const requiredFKs = [
|
||||
'submission_id',
|
||||
'park_submission_id',
|
||||
'ride_submission_id',
|
||||
'company_submission_id',
|
||||
'ride_model_submission_id',
|
||||
'photo_submission_id',
|
||||
'timeline_event_submission_id',
|
||||
'depends_on', // For dependency chain
|
||||
];
|
||||
|
||||
for (const fk of requiredFKs) {
|
||||
expect(schema[fk],
|
||||
`submission_items is missing FK: ${fk}`
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('submission_items has required metadata fields', async () => {
|
||||
const schema = await getTableSchema('submission_items');
|
||||
|
||||
const requiredFields = [
|
||||
'item_type',
|
||||
'action_type',
|
||||
'status',
|
||||
'order_index',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
expect(schema[field],
|
||||
`submission_items is missing field: ${field}`
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user