mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
1168 lines
34 KiB
TypeScript
1168 lines
34 KiB
TypeScript
import { supabase } from '@/integrations/supabase/client';
|
|
import { getErrorMessage } from './errorHandler';
|
|
import { logger } from './logger';
|
|
|
|
// Core submission item interface with dependencies
|
|
// Type safety for item_data will be added in Phase 5 after fixing components
|
|
|
|
export interface SubmissionItemWithDeps {
|
|
id: string;
|
|
submission_id: string;
|
|
item_type: string;
|
|
item_data: any; // Complex nested structure - will be typed properly in Phase 5
|
|
original_data: any; // Complex nested structure - will be typed properly in Phase 5
|
|
action_type?: 'create' | 'edit' | 'delete';
|
|
status: 'pending' | 'approved' | 'rejected' | 'flagged' | 'skipped'; // Matches ReviewStatus from statuses.ts
|
|
depends_on: string | null;
|
|
order_index: number;
|
|
approved_entity_id: string | null;
|
|
rejection_reason: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
dependencies?: SubmissionItemWithDeps[];
|
|
dependents?: SubmissionItemWithDeps[];
|
|
}
|
|
|
|
export interface DependencyConflict {
|
|
itemId: string;
|
|
type: 'missing_parent' | 'rejected_parent' | 'circular_dependency';
|
|
message: string;
|
|
suggestions: Array<{
|
|
action: 'link_existing' | 'cascade_reject' | 'escalate' | 'create_parent';
|
|
label: string;
|
|
entityId?: string;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Fetch all items for a submission with their dependencies
|
|
*/
|
|
export async function fetchSubmissionItems(submissionId: string): Promise<SubmissionItemWithDeps[]> {
|
|
const { data, error } = await supabase
|
|
.from('submission_items')
|
|
.select('*')
|
|
.eq('submission_id', submissionId)
|
|
.order('order_index', { ascending: true });
|
|
|
|
if (error) throw error;
|
|
|
|
// Cast the data to the correct type
|
|
return (data || []).map(item => ({
|
|
...item,
|
|
status: item.status as 'pending' | 'approved' | 'rejected',
|
|
})) as SubmissionItemWithDeps[];
|
|
}
|
|
|
|
/**
|
|
* Build dependency tree for submission items
|
|
*/
|
|
export function buildDependencyTree(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
|
|
const itemMap = new Map(items.map(item => [item.id, { ...item, dependencies: [], dependents: [] }]));
|
|
|
|
// Build relationships
|
|
items.forEach(item => {
|
|
if (item.depends_on) {
|
|
const parent = itemMap.get(item.depends_on);
|
|
const child = itemMap.get(item.id);
|
|
if (parent && child) {
|
|
parent.dependents = parent.dependents || [];
|
|
parent.dependents.push(child);
|
|
child.dependencies = child.dependencies || [];
|
|
child.dependencies.push(parent);
|
|
}
|
|
}
|
|
});
|
|
|
|
return Array.from(itemMap.values());
|
|
}
|
|
|
|
/**
|
|
* Detect dependency conflicts for selective approval
|
|
*/
|
|
export async function detectDependencyConflicts(
|
|
items: SubmissionItemWithDeps[],
|
|
selectedItemIds: string[]
|
|
): Promise<DependencyConflict[]> {
|
|
const conflicts: DependencyConflict[] = [];
|
|
const selectedSet = new Set(selectedItemIds);
|
|
|
|
for (const item of items) {
|
|
// Check if parent is rejected but child is selected
|
|
if (item.depends_on && selectedSet.has(item.id)) {
|
|
const parent = items.find(i => i.id === item.depends_on);
|
|
if (parent && (parent.status === 'rejected' || !selectedSet.has(parent.id))) {
|
|
|
|
// Find existing entities that could be linked
|
|
const suggestions: DependencyConflict['suggestions'] = [];
|
|
|
|
// Suggest creating parent
|
|
if (parent.status !== 'rejected') {
|
|
suggestions.push({
|
|
action: 'create_parent',
|
|
label: `Also approve ${parent.item_type}: ${parent.item_data.name}`,
|
|
});
|
|
}
|
|
|
|
// Suggest linking to existing entity
|
|
if (parent.item_type === 'park') {
|
|
const { data: parks } = await supabase
|
|
.from('parks')
|
|
.select('id, name')
|
|
.ilike('name', `%${parent.item_data.name}%`)
|
|
.limit(3);
|
|
|
|
parks?.forEach(park => {
|
|
suggestions.push({
|
|
action: 'link_existing',
|
|
label: `Link to existing park: ${park.name}`,
|
|
entityId: park.id,
|
|
});
|
|
});
|
|
}
|
|
|
|
suggestions.push({
|
|
action: 'escalate',
|
|
label: 'Escalate to admin for resolution',
|
|
});
|
|
|
|
conflicts.push({
|
|
itemId: item.id,
|
|
type: 'missing_parent',
|
|
message: `Cannot approve ${item.item_type} without its parent ${parent.item_type}`,
|
|
suggestions,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
/**
|
|
* Update individual submission item status
|
|
*/
|
|
export async function updateSubmissionItem(
|
|
itemId: string,
|
|
updates: Partial<SubmissionItemWithDeps>
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('submission_items')
|
|
.update(updates)
|
|
.eq('id', itemId);
|
|
|
|
if (error) throw error;
|
|
}
|
|
|
|
/**
|
|
* Approve multiple items with dependency handling
|
|
*/
|
|
export async function approveSubmissionItems(
|
|
items: SubmissionItemWithDeps[],
|
|
userId: string
|
|
): Promise<void> {
|
|
if (!userId) {
|
|
throw new Error('User authentication required to approve items');
|
|
}
|
|
|
|
// Sort by dependency order (parents first)
|
|
const sortedItems = topologicalSort(items);
|
|
|
|
// Build dependency resolution map
|
|
const dependencyMap = new Map<string, string>();
|
|
|
|
for (const item of sortedItems) {
|
|
let entityId: string | null = null;
|
|
let isEdit = false;
|
|
|
|
try {
|
|
// Determine if this is an edit by checking for entity_id in item_data
|
|
isEdit = !!(
|
|
item.item_data.park_id ||
|
|
item.item_data.ride_id ||
|
|
item.item_data.company_id ||
|
|
item.item_data.ride_model_id
|
|
);
|
|
|
|
// Create the entity based on type with dependency resolution
|
|
switch (item.item_type) {
|
|
case 'park':
|
|
entityId = await createPark(item.item_data, dependencyMap);
|
|
break;
|
|
case 'ride':
|
|
entityId = await createRide(item.item_data, dependencyMap);
|
|
break;
|
|
case 'manufacturer':
|
|
case 'operator':
|
|
case 'property_owner':
|
|
case 'designer':
|
|
entityId = await createCompany(item.item_data, item.item_type, dependencyMap);
|
|
break;
|
|
case 'ride_model':
|
|
entityId = await createRideModel(item.item_data, dependencyMap);
|
|
break;
|
|
case 'photo':
|
|
entityId = await approvePhotos(item.item_data, dependencyMap, userId, item.submission_id);
|
|
break;
|
|
}
|
|
|
|
if (!entityId) {
|
|
throw new Error(`Failed to create ${item.item_type}: no entity ID returned`);
|
|
}
|
|
|
|
// Update item status
|
|
await updateSubmissionItem(item.id, {
|
|
status: 'approved' as const,
|
|
approved_entity_id: entityId,
|
|
});
|
|
|
|
// Create version history (skip for photo type)
|
|
if (item.item_type !== 'photo') {
|
|
await createVersionForApprovedItem(
|
|
item.item_type,
|
|
entityId,
|
|
userId,
|
|
item.submission_id,
|
|
isEdit
|
|
);
|
|
}
|
|
|
|
// Add to dependency map for child items
|
|
dependencyMap.set(item.id, entityId);
|
|
|
|
} catch (error: unknown) {
|
|
const errorMsg = getErrorMessage(error);
|
|
logger.error('Error approving items', {
|
|
action: 'approve_submission_items',
|
|
error: errorMsg,
|
|
userId,
|
|
itemCount: items.length
|
|
});
|
|
|
|
// Update item with error status
|
|
await updateSubmissionItem(item.id, {
|
|
status: 'rejected' as const,
|
|
rejection_reason: `Failed to create entity: ${errorMsg}`,
|
|
});
|
|
|
|
throw new Error(`Failed to approve ${item.item_type}: ${errorMsg}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create version history for approved submission item
|
|
*
|
|
* NOTE: Versions are now created automatically via database triggers.
|
|
* This function is no longer needed since the relational versioning system
|
|
* handles version creation automatically when entities are inserted/updated.
|
|
*
|
|
* The trigger `create_relational_version()` reads session variables set by
|
|
* the edge function and creates versions in the appropriate `*_versions` table.
|
|
*/
|
|
async function createVersionForApprovedItem(
|
|
itemType: string,
|
|
entityId: string,
|
|
userId: string,
|
|
submissionId: string,
|
|
isEdit: boolean
|
|
): Promise<void> {
|
|
// No-op: Versions are created automatically by triggers
|
|
// The edge function sets:
|
|
// - app.current_user_id = original submitter
|
|
// - app.submission_id = submission ID
|
|
// Then the trigger creates the version automatically
|
|
console.debug(
|
|
`Version will be created automatically by trigger for ${itemType} ${entityId}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Topological sort for dependency-ordered processing
|
|
*/
|
|
function topologicalSort(items: SubmissionItemWithDeps[]): SubmissionItemWithDeps[] {
|
|
const sorted: SubmissionItemWithDeps[] = [];
|
|
const visited = new Set<string>();
|
|
const temp = new Set<string>();
|
|
|
|
function visit(item: SubmissionItemWithDeps) {
|
|
if (temp.has(item.id)) {
|
|
throw new Error('Circular dependency detected');
|
|
}
|
|
if (visited.has(item.id)) return;
|
|
|
|
temp.add(item.id);
|
|
|
|
if (item.dependencies) {
|
|
item.dependencies.forEach(dep => visit(dep));
|
|
}
|
|
|
|
temp.delete(item.id);
|
|
visited.add(item.id);
|
|
sorted.push(item);
|
|
}
|
|
|
|
items.forEach(item => {
|
|
if (!visited.has(item.id)) {
|
|
visit(item);
|
|
}
|
|
});
|
|
|
|
return sorted;
|
|
}
|
|
|
|
/**
|
|
* Extract image URLs from ImageAssignments structure
|
|
*/
|
|
function extractImageAssignments(images: any) {
|
|
if (!images || !images.uploaded || !Array.isArray(images.uploaded)) {
|
|
return {
|
|
banner_image_url: null,
|
|
banner_image_id: null,
|
|
card_image_url: null,
|
|
card_image_id: null,
|
|
};
|
|
}
|
|
|
|
const bannerImage = images.banner_assignment !== null && images.banner_assignment !== undefined
|
|
? images.uploaded[images.banner_assignment]
|
|
: null;
|
|
|
|
const cardImage = images.card_assignment !== null && images.card_assignment !== undefined
|
|
? images.uploaded[images.card_assignment]
|
|
: null;
|
|
|
|
return {
|
|
banner_image_url: bannerImage?.url || null,
|
|
banner_image_id: bannerImage?.cloudflare_id || null,
|
|
card_image_url: cardImage?.url || null,
|
|
card_image_id: cardImage?.cloudflare_id || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper functions to create entities with dependency resolution
|
|
*/
|
|
async function createPark(data: any, dependencyMap: Map<string, string>): Promise<string> {
|
|
const { transformParkData, validateSubmissionData } = await import('./entityTransformers');
|
|
const { ensureUniqueSlug } = await import('./slugUtils');
|
|
|
|
// Check if this is an edit (has park_id)
|
|
const isEdit = !!data.park_id;
|
|
|
|
if (isEdit) {
|
|
// Handle park edit
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
// Resolve location_id if location data is provided
|
|
let locationId = resolvedData.location_id;
|
|
if (resolvedData.location && !locationId) {
|
|
locationId = await resolveLocationId(resolvedData.location);
|
|
}
|
|
|
|
// Extract image assignments from ImageAssignments structure
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Update the park
|
|
const updateData: any = {
|
|
name: resolvedData.name,
|
|
slug: resolvedData.slug,
|
|
description: resolvedData.description || null,
|
|
park_type: resolvedData.park_type,
|
|
status: resolvedData.status,
|
|
opening_date: resolvedData.opening_date || null,
|
|
closing_date: resolvedData.closing_date || null,
|
|
website_url: resolvedData.website_url || null,
|
|
phone: resolvedData.phone || null,
|
|
email: resolvedData.email || null,
|
|
operator_id: resolvedData.operator_id || null,
|
|
property_owner_id: resolvedData.property_owner_id || null,
|
|
location_id: locationId || null,
|
|
...imageData,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
const { error } = await supabase
|
|
.from('parks')
|
|
.update(updateData)
|
|
.eq('id', data.park_id);
|
|
|
|
if (error) {
|
|
logger.error('Error updating park', {
|
|
action: 'update_park',
|
|
parkId: data.park_id,
|
|
error: error.message
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.park_id;
|
|
}
|
|
|
|
// Handle park creation
|
|
validateSubmissionData(data, 'Park');
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
// Resolve location_id if location data is provided
|
|
let locationId = resolvedData.location_id;
|
|
if (resolvedData.location && !locationId) {
|
|
locationId = await resolveLocationId(resolvedData.location);
|
|
}
|
|
|
|
// Ensure unique slug
|
|
const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'parks');
|
|
resolvedData.slug = uniqueSlug;
|
|
|
|
// Extract image assignments
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Transform to database format
|
|
const parkData = {
|
|
...transformParkData(resolvedData),
|
|
...imageData,
|
|
location_id: locationId || null,
|
|
};
|
|
|
|
// Insert into database
|
|
const { data: park, error } = await supabase
|
|
.from('parks')
|
|
.insert(parkData)
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
logger.error('Error creating park', {
|
|
action: 'create_park',
|
|
parkName: resolvedData.name,
|
|
error: error.message
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return park.id;
|
|
}
|
|
|
|
/**
|
|
* Resolve location data to a location_id
|
|
*
|
|
* SECURITY NOTE: Locations should go through moderation flow.
|
|
* Current implementation allows moderators to create locations directly during approval.
|
|
* This is acceptable as only moderators can call this function (via RLS on content_submissions).
|
|
*
|
|
* For user-submitted locations in the future, they should be submitted as separate
|
|
* submission_items with item_type='location' and go through the moderation queue.
|
|
*/
|
|
async function resolveLocationId(locationData: any): Promise<string | null> {
|
|
if (!locationData || !locationData.latitude || !locationData.longitude) {
|
|
return null;
|
|
}
|
|
|
|
// Check if location already exists by coordinates
|
|
const { data: existingLocation } = await supabase
|
|
.from('locations')
|
|
.select('id')
|
|
.eq('latitude', locationData.latitude)
|
|
.eq('longitude', locationData.longitude)
|
|
.maybeSingle();
|
|
|
|
if (existingLocation) {
|
|
return existingLocation.id;
|
|
}
|
|
|
|
// Create new location (moderator has permission via RLS)
|
|
// FUTURE TODO: Change this to submission flow for user-submitted locations
|
|
const { data: newLocation, error } = await supabase
|
|
.from('locations')
|
|
.insert({
|
|
name: locationData.name,
|
|
city: locationData.city || null,
|
|
state_province: locationData.state_province || null,
|
|
country: locationData.country,
|
|
postal_code: locationData.postal_code || null,
|
|
latitude: locationData.latitude,
|
|
longitude: locationData.longitude,
|
|
timezone: locationData.timezone || null,
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
logger.error('Error creating location', {
|
|
action: 'create_location',
|
|
locationData,
|
|
error: error.message
|
|
});
|
|
throw new Error(`Failed to create location: ${error.message}`);
|
|
}
|
|
|
|
return newLocation.id;
|
|
}
|
|
|
|
async function createRide(data: any, dependencyMap: Map<string, string>): Promise<string> {
|
|
const { transformRideData, validateSubmissionData } = await import('./entityTransformers');
|
|
const { ensureUniqueSlug } = await import('./slugUtils');
|
|
|
|
// Check if this is an edit (has ride_id)
|
|
const isEdit = !!data.ride_id;
|
|
|
|
if (isEdit) {
|
|
// Handle ride edit
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
// Extract image assignments from ImageAssignments structure
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Update the ride
|
|
const updateData: any = {
|
|
name: resolvedData.name,
|
|
slug: resolvedData.slug,
|
|
description: resolvedData.description,
|
|
category: resolvedData.category,
|
|
ride_sub_type: resolvedData.ride_sub_type,
|
|
status: resolvedData.status,
|
|
opening_date: resolvedData.opening_date,
|
|
closing_date: resolvedData.closing_date,
|
|
height_requirement: resolvedData.height_requirement,
|
|
age_requirement: resolvedData.age_requirement,
|
|
capacity_per_hour: resolvedData.capacity_per_hour,
|
|
duration_seconds: resolvedData.duration_seconds,
|
|
max_speed_kmh: resolvedData.max_speed_kmh,
|
|
max_height_meters: resolvedData.max_height_meters,
|
|
length_meters: resolvedData.length_meters,
|
|
inversions: resolvedData.inversions,
|
|
coaster_type: resolvedData.coaster_type,
|
|
seating_type: resolvedData.seating_type,
|
|
intensity_level: resolvedData.intensity_level,
|
|
drop_height_meters: resolvedData.drop_height_meters,
|
|
max_g_force: resolvedData.max_g_force,
|
|
manufacturer_id: resolvedData.manufacturer_id,
|
|
ride_model_id: resolvedData.ride_model_id,
|
|
...imageData,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
const { error } = await supabase
|
|
.from('rides')
|
|
.update(updateData)
|
|
.eq('id', data.ride_id);
|
|
|
|
if (error) {
|
|
console.error('Error updating ride:', error);
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.ride_id;
|
|
}
|
|
|
|
// Handle ride creation
|
|
validateSubmissionData(data, 'Ride');
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
if (!resolvedData.park_id) {
|
|
throw new Error('Ride must be associated with a park');
|
|
}
|
|
|
|
const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'rides');
|
|
resolvedData.slug = uniqueSlug;
|
|
|
|
// Extract image assignments
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Transform to database format
|
|
const rideData = { ...transformRideData(resolvedData), ...imageData };
|
|
|
|
const { data: ride, error } = await supabase
|
|
.from('rides')
|
|
.insert(rideData)
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
console.error('Error creating ride:', error);
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return ride.id;
|
|
}
|
|
|
|
async function createCompany(
|
|
data: any,
|
|
companyType: string,
|
|
dependencyMap: Map<string, string>
|
|
): Promise<string> {
|
|
const { transformCompanyData, validateSubmissionData } = await import('./entityTransformers');
|
|
const { ensureUniqueSlug } = await import('./slugUtils');
|
|
|
|
// Check if this is an edit (has company_id)
|
|
const isEdit = !!data.id;
|
|
|
|
if (isEdit) {
|
|
// Handle company edit
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
// Extract image assignments from ImageAssignments structure
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Update the company
|
|
const updateData: any = {
|
|
name: resolvedData.name,
|
|
slug: resolvedData.slug,
|
|
description: resolvedData.description || null,
|
|
website_url: resolvedData.website_url || null,
|
|
founded_year: resolvedData.founded_year || null,
|
|
headquarters_location: resolvedData.headquarters_location || null,
|
|
...imageData,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
const { error } = await supabase
|
|
.from('companies')
|
|
.update(updateData)
|
|
.eq('id', data.id);
|
|
|
|
if (error) {
|
|
console.error('Error updating company:', error);
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.id;
|
|
}
|
|
|
|
// Handle company creation
|
|
validateSubmissionData(data, 'Company');
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'companies');
|
|
resolvedData.slug = uniqueSlug;
|
|
|
|
// Extract image assignments
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Type guard for company type
|
|
type ValidCompanyType = 'manufacturer' | 'designer' | 'operator' | 'property_owner';
|
|
const validCompanyTypes: ValidCompanyType[] = ['manufacturer', 'designer', 'operator', 'property_owner'];
|
|
|
|
if (!validCompanyTypes.includes(companyType as ValidCompanyType)) {
|
|
throw new Error(`Invalid company type: ${companyType}`);
|
|
}
|
|
|
|
// Transform to database format
|
|
const companyData = { ...transformCompanyData(resolvedData, companyType as ValidCompanyType), ...imageData };
|
|
|
|
const { data: company, error } = await supabase
|
|
.from('companies')
|
|
.insert(companyData)
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
console.error('Error creating company:', error);
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return company.id;
|
|
}
|
|
|
|
async function createRideModel(data: any, dependencyMap: Map<string, string>): Promise<string> {
|
|
const { transformRideModelData, validateSubmissionData } = await import('./entityTransformers');
|
|
const { ensureUniqueSlug } = await import('./slugUtils');
|
|
|
|
// Validate input data
|
|
validateSubmissionData(data, 'Ride Model');
|
|
|
|
// Resolve dependencies
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
// Validate manufacturer_id is present (required for ride models)
|
|
if (!resolvedData.manufacturer_id) {
|
|
throw new Error('Ride model must be associated with a manufacturer');
|
|
}
|
|
|
|
// Ensure unique slug
|
|
const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'ride_models');
|
|
resolvedData.slug = uniqueSlug;
|
|
|
|
// Transform to database format
|
|
const modelData = transformRideModelData(resolvedData);
|
|
|
|
// Insert into database
|
|
const { data: model, error } = await supabase
|
|
.from('ride_models')
|
|
.insert(modelData)
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
console.error('Error creating ride model:', error);
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return model.id;
|
|
}
|
|
|
|
async function approvePhotos(data: any, dependencyMap: Map<string, string>, userId: string, submissionId: string): Promise<string> {
|
|
// Photos are already uploaded to Cloudflare
|
|
// Resolve dependencies for entity associations
|
|
const resolvedData = resolveDependencies(data, dependencyMap);
|
|
|
|
if (!resolvedData.photos || !Array.isArray(resolvedData.photos) || resolvedData.photos.length === 0) {
|
|
throw new Error('No photos found in submission');
|
|
}
|
|
|
|
const { entity_id, context, park_id, ride_id, company_id } = resolvedData;
|
|
|
|
// Determine entity_id and entity_type
|
|
let finalEntityId = entity_id;
|
|
let entityType = context;
|
|
|
|
// Support legacy field names
|
|
if (!finalEntityId) {
|
|
if (park_id) {
|
|
finalEntityId = park_id;
|
|
entityType = 'park';
|
|
} else if (ride_id) {
|
|
finalEntityId = ride_id;
|
|
entityType = 'ride';
|
|
} else if (company_id) {
|
|
finalEntityId = company_id;
|
|
// Need to determine company type from database
|
|
}
|
|
}
|
|
|
|
if (!finalEntityId || !entityType) {
|
|
throw new Error('Missing entity_id or context in photo submission');
|
|
}
|
|
|
|
// Insert photos into the photos table
|
|
const photosToInsert = resolvedData.photos.map((photo: any, index: number) => {
|
|
// Extract CloudFlare image ID from URL if not provided
|
|
let cloudflareImageId = photo.cloudflare_image_id;
|
|
if (!cloudflareImageId && photo.url) {
|
|
// URL format: https://imagedelivery.net/{account_hash}/{image_id}/{variant}
|
|
const urlParts = photo.url.split('/');
|
|
cloudflareImageId = urlParts[urlParts.length - 2];
|
|
}
|
|
|
|
return {
|
|
cloudflare_image_id: cloudflareImageId,
|
|
cloudflare_image_url: photo.url,
|
|
entity_type: entityType,
|
|
entity_id: finalEntityId,
|
|
title: photo.title || resolvedData.title,
|
|
caption: photo.caption,
|
|
photographer_credit: photo.photographer_credit,
|
|
date_taken: photo.date || photo.date_taken,
|
|
order_index: photo.order !== undefined ? photo.order : index,
|
|
is_featured: index === 0, // First photo is featured by default
|
|
submission_id: submissionId,
|
|
submitted_by: userId,
|
|
approved_by: userId,
|
|
approved_at: new Date().toISOString(),
|
|
};
|
|
});
|
|
|
|
const { data: insertedPhotos, error } = await supabase
|
|
.from('photos')
|
|
.insert(photosToInsert)
|
|
.select();
|
|
|
|
if (error) {
|
|
console.error('Error inserting photos:', error);
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
// Update entity's featured image if this is the first photo
|
|
if (insertedPhotos && insertedPhotos.length > 0) {
|
|
const firstPhoto = insertedPhotos[0];
|
|
await updateEntityFeaturedImage(
|
|
entityType,
|
|
finalEntityId,
|
|
firstPhoto.cloudflare_image_url,
|
|
firstPhoto.cloudflare_image_id
|
|
);
|
|
}
|
|
|
|
// Return the first photo URL for backwards compatibility
|
|
return resolvedData.photos[0].url;
|
|
}
|
|
|
|
/**
|
|
* Update entity's featured image fields
|
|
*/
|
|
async function updateEntityFeaturedImage(
|
|
entityType: string,
|
|
entityId: string,
|
|
imageUrl: string,
|
|
imageId: string
|
|
): Promise<void> {
|
|
try {
|
|
// Update based on entity type
|
|
if (entityType === 'park') {
|
|
const { data: existingPark } = await supabase
|
|
.from('parks')
|
|
.select('card_image_url')
|
|
.eq('id', entityId)
|
|
.single();
|
|
|
|
if (existingPark && !existingPark.card_image_url) {
|
|
await supabase
|
|
.from('parks')
|
|
.update({
|
|
card_image_url: imageUrl,
|
|
card_image_id: imageId,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', entityId);
|
|
}
|
|
} else if (entityType === 'ride') {
|
|
const { data: existingRide } = await supabase
|
|
.from('rides')
|
|
.select('card_image_url')
|
|
.eq('id', entityId)
|
|
.single();
|
|
|
|
if (existingRide && !existingRide.card_image_url) {
|
|
await supabase
|
|
.from('rides')
|
|
.update({
|
|
card_image_url: imageUrl,
|
|
card_image_id: imageId,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', entityId);
|
|
}
|
|
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
|
|
const { data: existingCompany } = await supabase
|
|
.from('companies')
|
|
.select('logo_url')
|
|
.eq('id', entityId)
|
|
.single();
|
|
|
|
if (existingCompany && !existingCompany.logo_url) {
|
|
await supabase
|
|
.from('companies')
|
|
.update({
|
|
logo_url: imageUrl,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', entityId);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error updating ${entityType} featured image:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve dependency references in submission data
|
|
* Replaces submission item IDs with actual database entity IDs
|
|
*/
|
|
function resolveDependencies(data: any, dependencyMap: Map<string, string>): any {
|
|
const resolved = { ...data };
|
|
|
|
// List of foreign key fields that may need resolution
|
|
const foreignKeys = [
|
|
'park_id',
|
|
'manufacturer_id',
|
|
'designer_id',
|
|
'operator_id',
|
|
'property_owner_id',
|
|
'ride_model_id',
|
|
'location_id',
|
|
];
|
|
|
|
// Resolve each foreign key if it's a submission item ID
|
|
for (const key of foreignKeys) {
|
|
if (resolved[key] && dependencyMap.has(resolved[key])) {
|
|
resolved[key] = dependencyMap.get(resolved[key]);
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Reset rejected items back to pending status
|
|
*/
|
|
export async function resetRejectedItemsToPending(
|
|
submissionId: string
|
|
): Promise<void> {
|
|
// Reset rejected submission items to pending
|
|
const { error: itemsError } = await supabase
|
|
.from('submission_items')
|
|
.update({
|
|
status: 'pending' as const,
|
|
rejection_reason: null,
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('submission_id', submissionId)
|
|
.eq('status', 'rejected');
|
|
|
|
if (itemsError) {
|
|
throw new Error(`Failed to reset items: ${itemsError.message}`);
|
|
}
|
|
|
|
// Reset parent submission to pending
|
|
const { error: submissionError } = await supabase
|
|
.from('content_submissions')
|
|
.update({
|
|
status: 'pending' as const,
|
|
reviewed_at: null,
|
|
reviewer_id: null,
|
|
reviewer_notes: null,
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('id', submissionId)
|
|
.in('status', ['rejected', 'partially_approved']);
|
|
|
|
if (submissionError) {
|
|
throw new Error(`Failed to reset submission: ${submissionError.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reject multiple items with optional cascade to dependents
|
|
*/
|
|
export async function rejectSubmissionItems(
|
|
items: SubmissionItemWithDeps[],
|
|
reason: string,
|
|
userId: string,
|
|
cascade: boolean = true
|
|
): Promise<void> {
|
|
if (!userId) {
|
|
throw new Error('User authentication required to reject items');
|
|
}
|
|
|
|
if (!reason || !reason.trim()) {
|
|
throw new Error('Rejection reason is required');
|
|
}
|
|
|
|
const itemsToReject = new Set<string>(items.map(i => i.id));
|
|
|
|
// If cascading, collect all dependent items
|
|
if (cascade) {
|
|
for (const item of items) {
|
|
await collectDependents(item, itemsToReject);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (error) {
|
|
console.error(`Error rejecting item ${itemId}:`, error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
await Promise.all(updates);
|
|
|
|
// Update parent submission status
|
|
const submissionId = items[0]?.submission_id;
|
|
if (submissionId) {
|
|
await updateSubmissionStatusAfterRejection(submissionId);
|
|
}
|
|
}
|
|
|
|
async function collectDependents(
|
|
item: SubmissionItemWithDeps,
|
|
rejectedSet: Set<string>
|
|
): Promise<void> {
|
|
if (item.dependents && item.dependents.length > 0) {
|
|
for (const dependent of item.dependents) {
|
|
rejectedSet.add(dependent.id);
|
|
await collectDependents(dependent, rejectedSet);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function updateSubmissionStatusAfterRejection(submissionId: string): Promise<void> {
|
|
// Get all items for this submission
|
|
const { data: allItems, error: fetchError } = await supabase
|
|
.from('submission_items')
|
|
.select('status')
|
|
.eq('submission_id', submissionId);
|
|
|
|
if (fetchError) {
|
|
console.error('Error fetching submission items:', fetchError);
|
|
return;
|
|
}
|
|
|
|
if (!allItems || allItems.length === 0) return;
|
|
|
|
const statuses = allItems.map(i => i.status);
|
|
const allRejected = statuses.every(s => s === 'rejected');
|
|
const allApproved = statuses.every(s => s === 'approved');
|
|
const anyPending = statuses.some(s => s === 'pending');
|
|
|
|
let newStatus: string;
|
|
if (allRejected) {
|
|
newStatus = 'rejected';
|
|
} else if (allApproved) {
|
|
newStatus = 'approved';
|
|
} else if (anyPending) {
|
|
newStatus = 'pending';
|
|
} else {
|
|
newStatus = 'partially_approved';
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('content_submissions')
|
|
.update({
|
|
status: newStatus,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', submissionId);
|
|
|
|
if (updateError) {
|
|
console.error('Error updating submission status:', updateError);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Edit a submission item - moderators edit directly, users auto-escalate
|
|
*/
|
|
export async function editSubmissionItem(
|
|
itemId: string,
|
|
newData: any,
|
|
userId: string
|
|
): Promise<void> {
|
|
if (!userId) {
|
|
throw new Error('User authentication required to edit items');
|
|
}
|
|
|
|
// Get current item to preserve original_data
|
|
const { data: currentItem, error: fetchError } = await supabase
|
|
.from('submission_items')
|
|
.select('*, submission:content_submissions(user_id)')
|
|
.eq('id', itemId)
|
|
.single();
|
|
|
|
if (fetchError) throw fetchError;
|
|
|
|
// Check if user has moderator/admin permissions
|
|
const { data: userRoles } = await supabase
|
|
.from('user_roles')
|
|
.select('role')
|
|
.eq('user_id', userId);
|
|
|
|
const isModerator = userRoles?.some(r =>
|
|
['moderator', 'admin', 'superuser'].includes(r.role)
|
|
);
|
|
|
|
// Preserve original_data if not already set
|
|
const originalData = currentItem.original_data || currentItem.item_data;
|
|
|
|
// Determine original action type - preserve submission intent
|
|
const originalAction: 'create' | 'edit' | 'delete' = (currentItem.action_type as 'create' | 'edit' | 'delete') ||
|
|
((currentItem.original_data && Object.keys(currentItem.original_data).length > 0) ? 'edit' : 'create');
|
|
|
|
if (isModerator) {
|
|
// Moderators can edit directly
|
|
const { error: updateError } = await supabase
|
|
.from('submission_items')
|
|
.update({
|
|
item_data: newData,
|
|
original_data: originalData,
|
|
action_type: originalAction, // Preserve original submission intent
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', itemId);
|
|
|
|
if (updateError) throw updateError;
|
|
|
|
// CRITICAL: Create version history if this is an entity edit (not photo)
|
|
// Only create version if this item has already been approved (has approved_entity_id)
|
|
if (currentItem.item_type !== 'photo' && currentItem.approved_entity_id) {
|
|
try {
|
|
await createVersionForApprovedItem(
|
|
currentItem.item_type,
|
|
currentItem.approved_entity_id,
|
|
userId,
|
|
currentItem.submission_id,
|
|
true // isEdit = true
|
|
);
|
|
|
|
console.log(`✅ Created version for manual edit of ${currentItem.item_type} ${currentItem.approved_entity_id}`);
|
|
} catch (versionError) {
|
|
console.error('Failed to create version for manual edit:', versionError);
|
|
// Don't fail the entire operation, just log the error
|
|
// The edit itself is still saved, just without version history
|
|
}
|
|
}
|
|
|
|
// Type guard for submission with user_id
|
|
interface SubmissionWithUser {
|
|
user_id: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
// Log admin action
|
|
await supabase
|
|
.from('admin_audit_log')
|
|
.insert({
|
|
admin_user_id: userId,
|
|
target_user_id: (currentItem.submission as SubmissionWithUser).user_id,
|
|
action: 'edit_submission_item',
|
|
details: {
|
|
item_id: itemId,
|
|
item_type: currentItem.item_type,
|
|
changes: 'Item data updated with version history',
|
|
version_created: !!(currentItem.approved_entity_id && currentItem.item_type !== 'photo'),
|
|
},
|
|
});
|
|
} else {
|
|
// Regular users: update data and auto-escalate
|
|
const { error: updateError } = await supabase
|
|
.from('submission_items')
|
|
.update({
|
|
item_data: newData,
|
|
original_data: originalData,
|
|
action_type: originalAction, // Preserve original submission intent
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', itemId);
|
|
|
|
if (updateError) throw updateError;
|
|
|
|
// Auto-escalate the parent submission
|
|
await escalateSubmission(
|
|
currentItem.submission_id,
|
|
`User requested edit to ${currentItem.item_type}`,
|
|
userId
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escalate submission for admin review
|
|
*/
|
|
export async function escalateSubmission(
|
|
submissionId: string,
|
|
reason: string,
|
|
userId: string
|
|
): Promise<void> {
|
|
if (!userId) {
|
|
throw new Error('User authentication required to escalate submission');
|
|
}
|
|
|
|
const { error } = await supabase
|
|
.from('content_submissions')
|
|
.update({
|
|
status: 'pending' as const,
|
|
escalation_reason: reason,
|
|
escalated_by: userId,
|
|
reviewer_notes: `Escalated: ${reason}`,
|
|
})
|
|
.eq('id', submissionId);
|
|
|
|
if (error) throw error;
|
|
}
|