mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 16:31:12 -05:00
1906 lines
58 KiB
TypeScript
1906 lines
58 KiB
TypeScript
import { supabase } from '@/lib/supabaseClient';
|
|
import { handleError, handleNonCriticalError, getErrorMessage } from './errorHandler';
|
|
import { extractCloudflareImageId } from './cloudflareImageUtils';
|
|
|
|
// Core submission item interface with dependencies
|
|
// NOTE: item_data and original_data use `unknown` because they contain dynamic structures
|
|
// that vary by item_type. Use type guards from @/types/submission-item-data.ts to access safely.
|
|
|
|
export interface SubmissionItemWithDeps {
|
|
id: string;
|
|
submission_id: string;
|
|
item_type: string;
|
|
item_data: unknown; // Dynamic structure - use type guards for safe access
|
|
original_data?: unknown; // Dynamic structure - use type guards for safe access
|
|
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;
|
|
}>;
|
|
}
|
|
|
|
export interface ConflictCheckResult {
|
|
hasConflict: boolean;
|
|
clientVersion: {
|
|
last_modified_at: string;
|
|
};
|
|
serverVersion?: {
|
|
last_modified_at: string;
|
|
last_modified_by: string;
|
|
modified_by_profile?: {
|
|
username: string;
|
|
display_name?: string;
|
|
};
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* Fetch all items for a submission with their dependencies
|
|
* Now joins with relational tables instead of using JSONB
|
|
*/
|
|
export async function fetchSubmissionItems(submissionId: string): Promise<SubmissionItemWithDeps[]> {
|
|
const { data, error } = await supabase
|
|
.from('submission_items')
|
|
.select(`
|
|
*,
|
|
park_submission:park_submissions!submission_items_park_submission_id_fkey(*),
|
|
ride_submission:ride_submissions!submission_items_ride_submission_id_fkey(*),
|
|
company_submission:company_submissions!submission_items_company_submission_id_fkey(*),
|
|
ride_model_submission:ride_model_submissions!submission_items_ride_model_submission_id_fkey(*),
|
|
timeline_event_submission:timeline_event_submissions!submission_items_timeline_event_submission_id_fkey(*),
|
|
photo_submission:photo_submissions!submission_items_photo_submission_id_fkey(
|
|
*,
|
|
photo_items:photo_submission_items(*)
|
|
)
|
|
`)
|
|
.eq('submission_id', submissionId)
|
|
.order('order_index', { ascending: true });
|
|
|
|
if (error) {
|
|
handleError(error, {
|
|
action: 'Fetch Submission Items',
|
|
metadata: { submissionId }
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
// Transform data to include relational data as item_data
|
|
return await Promise.all((data || []).map(async item => {
|
|
let item_data: unknown;
|
|
|
|
switch (item.item_type) {
|
|
case 'park': {
|
|
const parkSub = (item as any).park_submission;
|
|
// Fetch location from park_submission_locations if available
|
|
let locationData: any = null;
|
|
if (parkSub?.id) {
|
|
const { data, error: locationError } = await supabase
|
|
.from('park_submission_locations')
|
|
.select('*')
|
|
.eq('park_submission_id', parkSub.id)
|
|
.maybeSingle();
|
|
|
|
if (locationError) {
|
|
handleNonCriticalError(locationError, {
|
|
action: 'Fetch Park Submission Location',
|
|
metadata: { parkSubmissionId: parkSub.id, submissionId }
|
|
});
|
|
// Continue without location data - non-critical
|
|
} else {
|
|
locationData = data;
|
|
}
|
|
}
|
|
|
|
item_data = {
|
|
...parkSub,
|
|
// Transform park_submission_location → location for form compatibility
|
|
location: locationData || undefined
|
|
};
|
|
break;
|
|
}
|
|
case 'ride':
|
|
item_data = (item as any).ride_submission;
|
|
break;
|
|
case 'operator':
|
|
case 'manufacturer':
|
|
case 'designer':
|
|
case 'property_owner':
|
|
item_data = (item as any).company_submission;
|
|
break;
|
|
case 'ride_model':
|
|
item_data = (item as any).ride_model_submission;
|
|
break;
|
|
case 'milestone':
|
|
case 'timeline_event':
|
|
item_data = (item as any).timeline_event_submission;
|
|
break;
|
|
case 'photo':
|
|
case 'photo_edit':
|
|
case 'photo_delete':
|
|
item_data = {
|
|
...(item as any).photo_submission,
|
|
photos: (item as any).photo_submission?.photo_items || []
|
|
};
|
|
break;
|
|
default:
|
|
// Log warning for unknown types but don't crash
|
|
console.warn(`Unknown item_type: ${item.item_type}`);
|
|
item_data = null;
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
item_data,
|
|
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: [] as SubmissionItemWithDeps[], dependents: [] as SubmissionItemWithDeps[] }]));
|
|
|
|
// 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') {
|
|
const parentData = typeof parent.item_data === 'object' && parent.item_data !== null && !Array.isArray(parent.item_data)
|
|
? parent.item_data as Record<string, unknown>
|
|
: {};
|
|
const parentName = 'name' in parentData && typeof parentData.name === 'string' ? parentData.name : 'Unnamed';
|
|
|
|
suggestions.push({
|
|
action: 'create_parent',
|
|
label: `Also approve ${parent.item_type}: ${parentName}`,
|
|
});
|
|
}
|
|
|
|
// Suggest linking to existing entity
|
|
if (parent.item_type === 'park') {
|
|
const parentData = typeof parent.item_data === 'object' && parent.item_data !== null && !Array.isArray(parent.item_data)
|
|
? parent.item_data as Record<string, unknown>
|
|
: {};
|
|
const parentName = 'name' in parentData && typeof parentData.name === 'string' ? parentData.name : '';
|
|
|
|
const { data: parks } = await supabase
|
|
.from('parks')
|
|
.select('id, name')
|
|
.ilike('name', `%${parentName}%`)
|
|
.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 and data
|
|
*/
|
|
export async function updateSubmissionItem(
|
|
itemId: string,
|
|
updates: Partial<SubmissionItemWithDeps>
|
|
): Promise<void> {
|
|
const { item_data, original_data, ...cleanUpdates } = updates;
|
|
|
|
// Log submission item update start
|
|
console.info('[Submission Flow] Update item start', {
|
|
itemId,
|
|
hasItemData: !!item_data,
|
|
statusUpdate: cleanUpdates.status,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Update submission_items table
|
|
const { error } = await supabase
|
|
.from('submission_items')
|
|
.update(cleanUpdates)
|
|
.eq('id', itemId);
|
|
|
|
if (error) {
|
|
handleError(error, {
|
|
action: 'Update Submission Item',
|
|
metadata: { itemId, updates: cleanUpdates }
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
// If item_data is provided, update the relational table
|
|
if (item_data !== undefined) {
|
|
// Fetch the item to get its type and foreign keys
|
|
const { data: item, error: fetchError } = await supabase
|
|
.from('submission_items')
|
|
.select('item_type, park_submission_id, ride_submission_id, company_submission_id, ride_model_submission_id, timeline_event_submission_id, photo_submission_id')
|
|
.eq('id', itemId)
|
|
.single();
|
|
|
|
if (fetchError) throw fetchError;
|
|
if (!item) throw new Error(`Submission item ${itemId} not found`);
|
|
|
|
// Update the appropriate relational table
|
|
switch (item.item_type) {
|
|
case 'park': {
|
|
if (!item.park_submission_id) break;
|
|
const parkData = item_data as any;
|
|
const updateData: any = {
|
|
...parkData,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
// Remove fields that shouldn't be in park_submissions
|
|
delete updateData.location;
|
|
|
|
// Remove undefined fields
|
|
Object.keys(updateData).forEach(key => {
|
|
if (updateData[key] === undefined) delete updateData[key];
|
|
});
|
|
|
|
console.info('[Submission Flow] Saving park data', {
|
|
itemId,
|
|
parkSubmissionId: item.park_submission_id,
|
|
hasLocation: !!parkData.location,
|
|
fields: Object.keys(updateData),
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Update park_submissions
|
|
const { error: parkError } = await supabase
|
|
.from('park_submissions' as any)
|
|
.update(updateData)
|
|
.eq('id', item.park_submission_id);
|
|
|
|
if (parkError) {
|
|
console.error('[Submission Flow] Park update failed:', parkError);
|
|
throw parkError;
|
|
}
|
|
|
|
// Update or insert location if provided
|
|
if (parkData.location) {
|
|
const locationData = {
|
|
park_submission_id: item.park_submission_id,
|
|
name: parkData.location.name,
|
|
street_address: parkData.location.street_address || null,
|
|
city: parkData.location.city || null,
|
|
state_province: parkData.location.state_province || null,
|
|
country: parkData.location.country,
|
|
postal_code: parkData.location.postal_code || null,
|
|
latitude: parkData.location.latitude,
|
|
longitude: parkData.location.longitude,
|
|
timezone: parkData.location.timezone || null,
|
|
display_name: parkData.location.display_name || null
|
|
};
|
|
|
|
// Try to update first, if no rows affected, insert
|
|
const { error: locationError } = await supabase
|
|
.from('park_submission_locations' as any)
|
|
.upsert(locationData, {
|
|
onConflict: 'park_submission_id'
|
|
});
|
|
|
|
if (locationError) {
|
|
console.error('[Submission Flow] Location upsert failed:', locationError);
|
|
throw locationError;
|
|
}
|
|
|
|
console.info('[Submission Flow] Location saved', {
|
|
parkSubmissionId: item.park_submission_id,
|
|
locationName: locationData.name
|
|
});
|
|
}
|
|
|
|
console.info('[Submission Flow] Park data saved successfully');
|
|
break;
|
|
}
|
|
case 'ride': {
|
|
if (!item.ride_submission_id) break;
|
|
const { error: updateError } = await supabase
|
|
.from('ride_submissions')
|
|
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
|
|
.eq('id', item.ride_submission_id);
|
|
|
|
if (updateError) throw updateError;
|
|
break;
|
|
}
|
|
case 'operator':
|
|
case 'manufacturer':
|
|
case 'designer':
|
|
case 'property_owner': {
|
|
if (!item.company_submission_id) break;
|
|
const { error: updateError } = await supabase
|
|
.from('company_submissions')
|
|
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
|
|
.eq('id', item.company_submission_id);
|
|
|
|
if (updateError) throw updateError;
|
|
break;
|
|
}
|
|
case 'ride_model': {
|
|
if (!item.ride_model_submission_id) break;
|
|
const { error: updateError } = await supabase
|
|
.from('ride_model_submissions')
|
|
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
|
|
.eq('id', item.ride_model_submission_id);
|
|
|
|
if (updateError) throw updateError;
|
|
break;
|
|
}
|
|
case 'milestone':
|
|
case 'timeline_event': {
|
|
if (!item.timeline_event_submission_id) break;
|
|
const { error: updateError } = await supabase
|
|
.from('timeline_event_submissions')
|
|
.update({ ...(item_data as any), updated_at: new Date().toISOString() })
|
|
.eq('id', item.timeline_event_submission_id);
|
|
|
|
if (updateError) throw updateError;
|
|
break;
|
|
}
|
|
// Photo submissions handled separately due to complex structure
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
console.info('[Submission Flow] Approval process started', {
|
|
itemCount: items.length,
|
|
itemIds: items.map(i => i.id),
|
|
itemTypes: items.map(i => i.item_type),
|
|
userId,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// 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
|
|
const itemData = typeof item.item_data === 'object' && item.item_data !== null && !Array.isArray(item.item_data)
|
|
? item.item_data as Record<string, unknown>
|
|
: {};
|
|
|
|
isEdit = !!(
|
|
('park_id' in itemData && itemData.park_id) ||
|
|
('ride_id' in itemData && itemData.ride_id) ||
|
|
('company_id' in itemData && itemData.company_id) ||
|
|
('ride_model_id' in itemData && itemData.ride_model_id)
|
|
);
|
|
|
|
console.info('[Submission Flow] Processing item for approval', {
|
|
itemId: item.id,
|
|
itemType: item.item_type,
|
|
isEdit,
|
|
hasLocation: !!(itemData as any).location,
|
|
locationData: (itemData as any).location,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Create the entity based on type with dependency resolution
|
|
// PASS sortedItems to enable correct index-based resolution
|
|
switch (item.item_type) {
|
|
case 'park':
|
|
entityId = await createPark(item.item_data, dependencyMap, sortedItems);
|
|
break;
|
|
case 'ride':
|
|
entityId = await createRide(item.item_data, dependencyMap, sortedItems);
|
|
break;
|
|
case 'manufacturer':
|
|
case 'operator':
|
|
case 'property_owner':
|
|
case 'designer':
|
|
entityId = await createCompany(item.item_data, item.item_type, dependencyMap, sortedItems);
|
|
break;
|
|
case 'ride_model':
|
|
entityId = await createRideModel(item.item_data, dependencyMap, sortedItems);
|
|
break;
|
|
case 'photo':
|
|
entityId = await approvePhotos(item.item_data, dependencyMap, userId, item.submission_id, sortedItems);
|
|
break;
|
|
}
|
|
|
|
if (!entityId) {
|
|
throw new Error(`Failed to create ${item.item_type}: no entity ID returned`);
|
|
}
|
|
|
|
console.info('[Submission Flow] Entity created successfully', {
|
|
itemId: item.id,
|
|
itemType: item.item_type,
|
|
entityId,
|
|
isEdit,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// 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 using item.id as key
|
|
dependencyMap.set(item.id, entityId);
|
|
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: 'Approve Submission Items',
|
|
userId,
|
|
metadata: { itemCount: items.length, itemType: item.item_type }
|
|
});
|
|
|
|
// Update item with error status
|
|
await updateSubmissionItem(item.id, {
|
|
status: 'rejected' as const,
|
|
rejection_reason: `Failed to create entity: ${getErrorMessage(error)}`,
|
|
});
|
|
|
|
throw new Error(`Failed to approve ${item.item_type}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* 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>, sortedItems: SubmissionItemWithDeps[]): 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, sortedItems);
|
|
|
|
// 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) {
|
|
handleError(error, {
|
|
action: 'Update Park',
|
|
metadata: { parkId: data.park_id, parkName: resolvedData.name }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.park_id;
|
|
}
|
|
|
|
// Handle park creation
|
|
validateSubmissionData(data, 'Park');
|
|
const resolvedData = resolveDependencies(data, dependencyMap, sortedItems);
|
|
|
|
// 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) {
|
|
handleError(error, {
|
|
action: 'Create Park',
|
|
metadata: { parkName: resolvedData.name }
|
|
});
|
|
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,
|
|
street_address: locationData.street_address || null,
|
|
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) {
|
|
handleError(error, {
|
|
action: 'Create Location',
|
|
metadata: { locationData }
|
|
});
|
|
throw new Error(`Failed to create location: ${error.message}`);
|
|
}
|
|
|
|
return newLocation.id;
|
|
}
|
|
|
|
async function createRide(data: any, dependencyMap: Map<string, string>, sortedItems: SubmissionItemWithDeps[]): 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, sortedItems);
|
|
|
|
// 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) {
|
|
handleError(error, {
|
|
action: 'Update Ride',
|
|
metadata: { rideId: data.ride_id, rideName: resolvedData.name }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.ride_id;
|
|
}
|
|
|
|
// Handle ride creation
|
|
validateSubmissionData(data, 'Ride');
|
|
const resolvedData = resolveDependencies(data, dependencyMap, sortedItems);
|
|
|
|
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) {
|
|
handleError(error, {
|
|
action: 'Create Ride',
|
|
metadata: { rideName: resolvedData.name }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return ride.id;
|
|
}
|
|
|
|
async function createCompany(
|
|
data: any,
|
|
companyType: string,
|
|
dependencyMap: Map<string, string>,
|
|
sortedItems: SubmissionItemWithDeps[]
|
|
): 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, sortedItems);
|
|
|
|
// 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) {
|
|
handleError(error, {
|
|
action: 'Update Company',
|
|
metadata: { companyId: data.id, companyName: resolvedData.name, companyType }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.id;
|
|
}
|
|
|
|
// Handle company creation
|
|
validateSubmissionData(data, 'Company');
|
|
const resolvedData = resolveDependencies(data, dependencyMap, sortedItems);
|
|
|
|
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) {
|
|
handleError(error, {
|
|
action: 'Create Company',
|
|
metadata: { companyName: resolvedData.name, companyType }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return company.id;
|
|
}
|
|
|
|
async function createRideModel(data: any, dependencyMap: Map<string, string>, sortedItems: SubmissionItemWithDeps[]): Promise<string> {
|
|
const { transformRideModelData, validateSubmissionData } = await import('./entityTransformers');
|
|
const { ensureUniqueSlug } = await import('./slugUtils');
|
|
|
|
// Check if this is an edit (has ride_model_id)
|
|
const isEdit = !!data.ride_model_id;
|
|
|
|
if (isEdit) {
|
|
// Handle ride model edit
|
|
const resolvedData = resolveDependencies(data, dependencyMap, sortedItems);
|
|
|
|
// Extract image assignments from ImageAssignments structure
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Update the ride model
|
|
const updateData: any = {
|
|
name: resolvedData.name,
|
|
slug: resolvedData.slug,
|
|
category: resolvedData.category,
|
|
ride_type: resolvedData.ride_type || null,
|
|
description: resolvedData.description || null,
|
|
manufacturer_id: resolvedData.manufacturer_id,
|
|
...imageData,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
const { error } = await supabase
|
|
.from('ride_models')
|
|
.update(updateData)
|
|
.eq('id', data.ride_model_id);
|
|
|
|
if (error) {
|
|
handleError(error, {
|
|
action: 'Update Ride Model',
|
|
metadata: { rideModelId: data.ride_model_id, modelName: resolvedData.name }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return data.ride_model_id;
|
|
}
|
|
|
|
// Handle ride model creation
|
|
validateSubmissionData(data, 'Ride Model');
|
|
const resolvedData = resolveDependencies(data, dependencyMap, sortedItems);
|
|
|
|
// 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;
|
|
|
|
// Extract image assignments
|
|
const imageData = extractImageAssignments(resolvedData.images);
|
|
|
|
// Transform to database format
|
|
const modelData = {
|
|
...transformRideModelData(resolvedData),
|
|
...imageData
|
|
};
|
|
|
|
// Insert into database
|
|
const { data: model, error } = await supabase
|
|
.from('ride_models')
|
|
.insert(modelData)
|
|
.select('id')
|
|
.single();
|
|
|
|
if (error) {
|
|
handleError(error, {
|
|
action: 'Create Ride Model',
|
|
metadata: { modelName: resolvedData.name }
|
|
});
|
|
throw new Error(`Database error: ${error.message}`);
|
|
}
|
|
|
|
return model.id;
|
|
}
|
|
|
|
async function approvePhotos(data: any, dependencyMap: Map<string, string>, userId: string, submissionId: string, sortedItems: SubmissionItemWithDeps[]): Promise<string> {
|
|
// Photos are already uploaded to Cloudflare
|
|
// Resolve dependencies for entity associations
|
|
const resolvedData = resolveDependencies(data, dependencyMap, sortedItems);
|
|
|
|
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
|
|
// Supports both old imagedelivery.net and new cdn.thrillwiki.com URLs
|
|
let cloudflareImageId = photo.cloudflare_image_id;
|
|
if (!cloudflareImageId && photo.url) {
|
|
cloudflareImageId = extractCloudflareImageId(photo.url);
|
|
|
|
// Fallback: parse from URL structure
|
|
if (!cloudflareImageId) {
|
|
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) {
|
|
handleError(error, {
|
|
action: 'Insert Photos',
|
|
metadata: { photoCount: photosToInsert.length, entityType, entityId: finalEntityId }
|
|
});
|
|
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) {
|
|
handleNonCriticalError(error, {
|
|
action: 'Update Entity Featured Image',
|
|
metadata: { entityType, entityId }
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve dependency references in item_data by looking up approved entity IDs
|
|
* Replaces temporary references (_temp_*_ref) with actual database entity IDs
|
|
*
|
|
* FIXED: Now uses sortedItems array for stable index-based resolution
|
|
* instead of unreliable Array.from(dependencyMap.keys())[refIndex]
|
|
*/
|
|
function resolveDependencies(data: any, dependencyMap: Map<string, string>, sortedItems: SubmissionItemWithDeps[]): 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 temporary references using sortedItems array (FIXED)
|
|
if (resolved._temp_park_ref !== undefined) {
|
|
const refIndex = resolved._temp_park_ref;
|
|
if (refIndex >= 0 && refIndex < sortedItems.length) {
|
|
const refItemId = sortedItems[refIndex].id;
|
|
if (dependencyMap.has(refItemId)) {
|
|
resolved.park_id = dependencyMap.get(refItemId);
|
|
}
|
|
}
|
|
delete resolved._temp_park_ref;
|
|
}
|
|
|
|
if (resolved._temp_manufacturer_ref !== undefined) {
|
|
const refIndex = resolved._temp_manufacturer_ref;
|
|
if (refIndex >= 0 && refIndex < sortedItems.length) {
|
|
const refItemId = sortedItems[refIndex].id;
|
|
if (dependencyMap.has(refItemId)) {
|
|
resolved.manufacturer_id = dependencyMap.get(refItemId);
|
|
}
|
|
}
|
|
delete resolved._temp_manufacturer_ref;
|
|
}
|
|
|
|
if (resolved._temp_designer_ref !== undefined) {
|
|
const refIndex = resolved._temp_designer_ref;
|
|
if (refIndex >= 0 && refIndex < sortedItems.length) {
|
|
const refItemId = sortedItems[refIndex].id;
|
|
if (dependencyMap.has(refItemId)) {
|
|
resolved.designer_id = dependencyMap.get(refItemId);
|
|
}
|
|
}
|
|
delete resolved._temp_designer_ref;
|
|
}
|
|
|
|
if (resolved._temp_operator_ref !== undefined) {
|
|
const refIndex = resolved._temp_operator_ref;
|
|
if (refIndex >= 0 && refIndex < sortedItems.length) {
|
|
const refItemId = sortedItems[refIndex].id;
|
|
if (dependencyMap.has(refItemId)) {
|
|
resolved.operator_id = dependencyMap.get(refItemId);
|
|
}
|
|
}
|
|
delete resolved._temp_operator_ref;
|
|
}
|
|
|
|
if (resolved._temp_property_owner_ref !== undefined) {
|
|
const refIndex = resolved._temp_property_owner_ref;
|
|
if (refIndex >= 0 && refIndex < sortedItems.length) {
|
|
const refItemId = sortedItems[refIndex].id;
|
|
if (dependencyMap.has(refItemId)) {
|
|
resolved.property_owner_id = dependencyMap.get(refItemId);
|
|
}
|
|
}
|
|
delete resolved._temp_property_owner_ref;
|
|
}
|
|
|
|
if (resolved._temp_ride_model_ref !== undefined) {
|
|
const refIndex = resolved._temp_ride_model_ref;
|
|
if (refIndex >= 0 && refIndex < sortedItems.length) {
|
|
const refItemId = sortedItems[refIndex].id;
|
|
if (dependencyMap.has(refItemId)) {
|
|
resolved.ride_model_id = dependencyMap.get(refItemId);
|
|
}
|
|
}
|
|
delete resolved._temp_ride_model_ref;
|
|
}
|
|
|
|
// 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) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
handleNonCriticalError(fetchError, {
|
|
action: 'Fetch Submission Items for Status Update',
|
|
metadata: { submissionId }
|
|
});
|
|
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) {
|
|
handleNonCriticalError(updateError, {
|
|
action: 'Update Submission Status After Rejection',
|
|
metadata: { submissionId, newStatus }
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 with relational data
|
|
const { data: currentItem, error: fetchError } = await supabase
|
|
.from('submission_items')
|
|
.select(`
|
|
*,
|
|
submission:content_submissions!submission_id(user_id, status)
|
|
`)
|
|
.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)
|
|
);
|
|
|
|
// Determine original action type - preserve submission intent
|
|
const originalAction: 'create' | 'edit' | 'delete' = (currentItem.action_type as 'create' | 'edit' | 'delete') || 'create';
|
|
|
|
if (isModerator) {
|
|
// Track edit in edit history table
|
|
const changes = {
|
|
timestamp: new Date().toISOString(),
|
|
editor: userId
|
|
};
|
|
|
|
// Moderators can edit directly - update relational table
|
|
// Note: item_data and original_data columns have been removed
|
|
// Updates now go directly to relational tables (park_submissions, ride_submissions, etc.)
|
|
const { error: updateError } = await supabase
|
|
.from('submission_items')
|
|
.update({
|
|
action_type: originalAction,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', itemId);
|
|
|
|
if (updateError) throw updateError;
|
|
|
|
// Update relational table with new data based on item type
|
|
if (currentItem.item_type === 'park') {
|
|
// For parks, store location in temp_location_data if provided
|
|
const updateData: any = { ...newData };
|
|
|
|
// If location object is provided, store it in temp_location_data
|
|
if (newData.location) {
|
|
updateData.temp_location_data = {
|
|
name: newData.location.name,
|
|
street_address: newData.location.street_address || null,
|
|
city: newData.location.city || null,
|
|
state_province: newData.location.state_province || null,
|
|
country: newData.location.country,
|
|
latitude: newData.location.latitude,
|
|
longitude: newData.location.longitude,
|
|
timezone: newData.location.timezone || null,
|
|
postal_code: newData.location.postal_code || null,
|
|
display_name: newData.location.display_name
|
|
};
|
|
delete updateData.location; // Remove the nested object
|
|
}
|
|
|
|
// Update park_submissions table
|
|
const { error: parkUpdateError } = await supabase
|
|
.from('park_submissions')
|
|
.update(updateData)
|
|
.eq('submission_id', currentItem.submission_id);
|
|
|
|
if (parkUpdateError) throw parkUpdateError;
|
|
|
|
} else if (currentItem.item_type === 'ride') {
|
|
const { error: rideUpdateError } = await supabase
|
|
.from('ride_submissions')
|
|
.update(newData)
|
|
.eq('submission_id', currentItem.submission_id);
|
|
|
|
if (rideUpdateError) throw rideUpdateError;
|
|
|
|
} else if (currentItem.item_type === 'manufacturer') {
|
|
const { error: manufacturerUpdateError } = await supabase
|
|
.from('company_submissions')
|
|
.update(newData)
|
|
.eq('submission_id', currentItem.submission_id)
|
|
.eq('company_type', 'manufacturer');
|
|
|
|
if (manufacturerUpdateError) throw manufacturerUpdateError;
|
|
|
|
} else if (currentItem.item_type === 'designer') {
|
|
const { error: designerUpdateError } = await supabase
|
|
.from('company_submissions')
|
|
.update(newData)
|
|
.eq('submission_id', currentItem.submission_id)
|
|
.eq('company_type', 'designer');
|
|
|
|
if (designerUpdateError) throw designerUpdateError;
|
|
|
|
} else if (currentItem.item_type === 'operator') {
|
|
const { error: operatorUpdateError } = await supabase
|
|
.from('company_submissions')
|
|
.update(newData)
|
|
.eq('submission_id', currentItem.submission_id)
|
|
.eq('company_type', 'operator');
|
|
|
|
if (operatorUpdateError) throw operatorUpdateError;
|
|
|
|
} else if (currentItem.item_type === 'property_owner') {
|
|
const { error: ownerUpdateError } = await supabase
|
|
.from('company_submissions')
|
|
.update(newData)
|
|
.eq('submission_id', currentItem.submission_id)
|
|
.eq('company_type', 'property_owner');
|
|
|
|
if (ownerUpdateError) throw ownerUpdateError;
|
|
|
|
} else if (currentItem.item_type === 'ride_model') {
|
|
const { error: modelUpdateError } = await supabase
|
|
.from('ride_model_submissions')
|
|
.update(newData)
|
|
.eq('submission_id', currentItem.submission_id);
|
|
|
|
if (modelUpdateError) throw modelUpdateError;
|
|
}
|
|
|
|
// Phase 4: Record edit history
|
|
const { data: historyData, error: historyError } = await supabase
|
|
.from('item_edit_history')
|
|
.insert({
|
|
item_id: itemId,
|
|
edited_by: userId,
|
|
changed_fields: Object.keys(changes),
|
|
edit_reason: 'Direct edit by moderator',
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
// Insert field changes relationally (NO JSON!)
|
|
if (!historyError && historyData) {
|
|
const fieldChanges = Object.entries(changes).map(([fieldName, change]: [string, any]) => ({
|
|
edit_history_id: historyData.id,
|
|
field_name: fieldName,
|
|
old_value: String(change.old ?? ''),
|
|
new_value: String(change.new ?? ''),
|
|
}));
|
|
|
|
const { error: fieldChangesError } = await supabase
|
|
.from('item_field_changes')
|
|
.insert(fieldChanges);
|
|
|
|
if (fieldChangesError) {
|
|
handleNonCriticalError(fieldChangesError, {
|
|
action: 'Record Field Changes',
|
|
metadata: { editHistoryId: historyData.id }
|
|
});
|
|
}
|
|
}
|
|
|
|
if (historyError) {
|
|
handleNonCriticalError(historyError, {
|
|
action: 'Record Edit History',
|
|
metadata: { itemId, editorId: userId }
|
|
});
|
|
// Don't fail the whole operation if history tracking fails
|
|
}
|
|
|
|
// 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
|
|
);
|
|
} catch (versionError) {
|
|
handleNonCriticalError(versionError, {
|
|
action: 'Create Version for Manual Edit',
|
|
metadata: {
|
|
itemType: currentItem.item_type,
|
|
entityId: currentItem.approved_entity_id
|
|
}
|
|
});
|
|
// 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 submission items and auto-escalate
|
|
// Note: item_data and original_data columns have been removed
|
|
const { error: updateError } = await supabase
|
|
.from('submission_items')
|
|
.update({
|
|
action_type: originalAction,
|
|
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');
|
|
}
|
|
|
|
// Fetch submission details for audit log
|
|
const { data: submission } = await supabase
|
|
.from('content_submissions')
|
|
.select('user_id, submission_type')
|
|
.eq('id', submissionId)
|
|
.single();
|
|
|
|
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;
|
|
|
|
// Log audit trail for escalation
|
|
if (submission) {
|
|
try {
|
|
await supabase.rpc('log_admin_action', {
|
|
_admin_user_id: userId,
|
|
_target_user_id: submission.user_id,
|
|
_action: 'submission_escalated',
|
|
_details: {
|
|
submission_id: submissionId,
|
|
submission_type: submission.submission_type,
|
|
escalation_reason: reason
|
|
}
|
|
});
|
|
} catch (auditError) {
|
|
handleNonCriticalError(auditError, {
|
|
action: 'Log Escalation Audit',
|
|
metadata: { submissionId }
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Phase 4: Fetch edit history for a submission item
|
|
* Returns all edits with editor information
|
|
*/
|
|
export async function fetchEditHistory(itemId: string) {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('item_edit_history')
|
|
.select(`
|
|
id,
|
|
item_id,
|
|
edited_at,
|
|
edit_reason,
|
|
changed_fields,
|
|
field_changes:item_field_changes(
|
|
id,
|
|
field_name,
|
|
old_value,
|
|
new_value
|
|
),
|
|
editor:profiles!item_edit_history_edited_by_fkey(
|
|
user_id,
|
|
username,
|
|
display_name,
|
|
avatar_url
|
|
)
|
|
`)
|
|
.eq('item_id', itemId)
|
|
.order('edited_at', { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
return data || [];
|
|
} catch (error: unknown) {
|
|
handleNonCriticalError(error, {
|
|
action: 'Fetch Edit History',
|
|
metadata: { itemId }
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a submission has been modified since the client last loaded it
|
|
* Used for optimistic locking to prevent concurrent edit conflicts
|
|
*/
|
|
export async function checkSubmissionConflict(
|
|
submissionId: string,
|
|
clientLastModified: string
|
|
): Promise<ConflictCheckResult> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('content_submissions')
|
|
.select(`
|
|
last_modified_at,
|
|
last_modified_by,
|
|
profiles:last_modified_by (
|
|
username,
|
|
display_name,
|
|
avatar_url
|
|
)
|
|
`)
|
|
.eq('id', submissionId)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
if (!data.last_modified_at) {
|
|
return {
|
|
hasConflict: false,
|
|
clientVersion: { last_modified_at: clientLastModified },
|
|
};
|
|
}
|
|
|
|
const serverTimestamp = new Date(data.last_modified_at).getTime();
|
|
const clientTimestamp = new Date(clientLastModified).getTime();
|
|
|
|
return {
|
|
hasConflict: serverTimestamp > clientTimestamp,
|
|
clientVersion: {
|
|
last_modified_at: clientLastModified,
|
|
},
|
|
serverVersion: {
|
|
last_modified_at: data.last_modified_at,
|
|
last_modified_by: (data.last_modified_by ?? undefined) as string,
|
|
modified_by_profile: data.profiles as any,
|
|
},
|
|
};
|
|
} catch (error: unknown) {
|
|
handleNonCriticalError(error, {
|
|
action: 'Check Submission Conflict',
|
|
metadata: { submissionId }
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch recent versions of submission items for conflict resolution
|
|
*/
|
|
export async function fetchSubmissionVersions(
|
|
submissionId: string,
|
|
limit: number = 10
|
|
) {
|
|
try {
|
|
// Get all item IDs for this submission
|
|
const { data: items, error: itemsError } = await supabase
|
|
.from('submission_items')
|
|
.select('id')
|
|
.eq('submission_id', submissionId);
|
|
|
|
if (itemsError) throw itemsError;
|
|
if (!items || items.length === 0) return [];
|
|
|
|
const itemIds = items.map(i => i.id);
|
|
|
|
// Fetch edit history for all items
|
|
const { data, error } = await supabase
|
|
.from('item_edit_history')
|
|
.select(`
|
|
id,
|
|
item_id,
|
|
changes,
|
|
edited_at,
|
|
editor:profiles!item_edit_history_editor_id_fkey (
|
|
user_id,
|
|
username,
|
|
display_name,
|
|
avatar_url
|
|
)
|
|
`)
|
|
.in('item_id', itemIds)
|
|
.order('edited_at', { ascending: false })
|
|
.limit(limit);
|
|
|
|
if (error) throw error;
|
|
|
|
return data || [];
|
|
} catch (error: unknown) {
|
|
handleNonCriticalError(error, {
|
|
action: 'Fetch Submission Versions',
|
|
metadata: { submissionId }
|
|
});
|
|
return [];
|
|
}
|
|
}
|