Files
thrilltrack-explorer/src/lib/entitySubmissionHelpers.ts
2025-10-10 19:28:15 +00:00

887 lines
26 KiB
TypeScript

import { supabase } from '@/integrations/supabase/client';
import type { Json } from '@/integrations/supabase/types';
import { ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { uploadPendingImages } from './imageUploadHelper';
/**
* ═══════════════════════════════════════════════════════════════════
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
* ═══════════════════════════════════════════════════════════════════
*
* ⚠️ NEVER STORE JSON IN SQL COLUMNS ⚠️
*
* content_submissions.content should ONLY contain:
* ✅ action: 'create' | 'edit' | 'delete'
* ✅ Minimal reference IDs (entity_id, parent_id, etc.) - MAX 3 fields
* ❌ NO actual form data
* ❌ NO submission content
* ❌ NO large objects
*
* ALL actual data MUST go in:
* ✅ submission_items.item_data (new data)
* ✅ submission_items.original_data (for edits)
* ✅ Specialized relational tables:
* - photo_submissions + photo_submission_items
* - park_submissions
* - ride_submissions
* - company_submissions
* - ride_model_submissions
*
* If your data is relational, model it relationally.
* JSON blobs destroy:
* - Queryability (can't filter/join)
* - Performance (slower, larger)
* - Data integrity (no constraints)
* - Maintainability (impossible to refactor)
*
* EXAMPLES:
*
* ✅ CORRECT:
* content: { action: 'create' }
* content: { action: 'edit', park_id: uuid }
* content: { action: 'delete', photo_id: uuid }
*
* ❌ WRONG:
* content: { name: '...', description: '...', ...formData }
* content: { photos: [...], metadata: {...} }
* content: data // entire object dump
*
* ═══════════════════════════════════════════════════════════════════
*/
export interface ParkFormData {
name: string;
slug: string;
description?: string;
park_type: string;
status: string;
opening_date?: string;
closing_date?: string;
website_url?: string;
phone?: string;
email?: string;
operator_id?: string;
property_owner_id?: string;
// Location can be stored as object for new submissions or ID for editing
location?: {
name: string;
city?: string;
state_province?: string;
country: string;
postal_code?: string;
latitude: number;
longitude: number;
timezone?: string;
display_name: string;
};
location_id?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}
export interface RideFormData {
name: string;
slug: string;
description?: string;
category: string;
status: string;
park_id: string;
manufacturer_id?: string;
designer_id?: string;
ride_model_id?: string;
opening_date?: string;
closing_date?: string;
max_speed_kmh?: number;
max_height_meters?: number;
length_meters?: number;
duration_seconds?: number;
capacity_per_hour?: number;
height_requirement?: number;
age_requirement?: number;
inversions?: number;
drop_height_meters?: number;
max_g_force?: number;
intensity_level?: string;
coaster_type?: string;
seating_type?: string;
ride_sub_type?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}
export interface CompanyFormData {
name: string;
slug: string;
description?: string;
person_type: 'company' | 'individual' | 'firm' | 'organization';
founded_year?: number;
founded_date?: string;
founded_date_precision?: string;
headquarters_location?: string;
website_url?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new park for creation through the moderation queue.
* This is the ONLY correct way to create parks.
*
* DO NOT use direct database inserts:
* ❌ await supabase.from('parks').insert(data) // BYPASSES MODERATION!
* ✅ await submitParkCreation(data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param data - The park form data to submit
* @param userId - The ID of the user submitting the park
* @returns Object containing submitted boolean and submissionId
*/
export async function submitParkCreation(
data: ParkFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Upload any pending local images first
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error) {
console.error('Failed to upload images for park creation:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'park',
content: {
action: 'create'
},
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual park data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'park',
item_data: {
...data,
images: processedImages as unknown as Json
},
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits an update to an existing park through the moderation queue.
* This is the ONLY correct way to update parks.
*
* DO NOT use direct database updates:
* ❌ await supabase.from('parks').update(data) // BYPASSES MODERATION!
* ✅ await submitParkUpdate(parkId, data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param parkId - The ID of the park to update
* @param data - The updated park form data
* @param userId - The ID of the user submitting the update
* @returns Object containing submitted boolean and submissionId
*/
export async function submitParkUpdate(
parkId: string,
data: ParkFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Fetch existing park data first
const { data: existingPark, error: fetchError } = await supabase
.from('parks')
.select('*')
.eq('id', parkId)
.single();
if (fetchError) throw new Error(`Failed to fetch park: ${fetchError.message}`);
if (!existingPark) throw new Error('Park not found');
// Upload any pending local images first
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error) {
console.error('Failed to upload images for park update:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'park',
content: {
action: 'edit',
park_id: parkId
},
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual park data AND original data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'park',
item_data: {
...data,
park_id: parkId,
images: processedImages as any
},
original_data: JSON.parse(JSON.stringify(existingPark)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new ride for creation through the moderation queue.
* This is the ONLY correct way to create rides.
*
* DO NOT use direct database inserts:
* ❌ await supabase.from('rides').insert(data) // BYPASSES MODERATION!
* ✅ await submitRideCreation(data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param data - The ride form data to submit
* @param userId - The ID of the user submitting the ride
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideCreation(
data: RideFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Upload any pending local images first
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error) {
console.error('Failed to upload images for ride creation:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride',
content: {
action: 'create'
},
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual ride data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride',
item_data: {
...data,
images: processedImages as unknown as Json
},
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits an update to an existing ride through the moderation queue.
* This is the ONLY correct way to update rides.
*
* DO NOT use direct database updates:
* ❌ await supabase.from('rides').update(data) // BYPASSES MODERATION!
* ✅ await submitRideUpdate(rideId, data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param rideId - The ID of the ride to update
* @param data - The updated ride form data
* @param userId - The ID of the user submitting the update
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideUpdate(
rideId: string,
data: RideFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Fetch existing ride data first
const { data: existingRide, error: fetchError } = await supabase
.from('rides')
.select('*')
.eq('id', rideId)
.single();
if (fetchError) throw new Error(`Failed to fetch ride: ${fetchError.message}`);
if (!existingRide) throw new Error('Ride not found');
// Upload any pending local images first
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error) {
console.error('Failed to upload images for ride update:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride',
content: {
action: 'edit',
ride_id: rideId
},
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual ride data AND original data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride',
item_data: {
...data,
ride_id: rideId,
images: processedImages as any
},
original_data: JSON.parse(JSON.stringify(existingRide)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new manufacturer for creation through the moderation queue.
*/
export async function submitManufacturerCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'manufacturer',
content: { action: 'create' },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'manufacturer',
item_data: {
...data,
company_type: 'manufacturer',
images: processedImages as unknown as Json
},
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitManufacturerUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch manufacturer: ${fetchError.message}`);
if (!existingCompany) throw new Error('Manufacturer not found');
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'manufacturer',
content: { action: 'edit', company_id: companyId },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'manufacturer',
item_data: {
...data,
company_id: companyId,
company_type: 'manufacturer',
images: processedImages as any
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitDesignerCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'designer',
content: { action: 'create' },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'designer',
item_data: {
...data,
company_type: 'designer',
images: processedImages as unknown as Json
},
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitDesignerUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch designer: ${fetchError.message}`);
if (!existingCompany) throw new Error('Designer not found');
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'designer',
content: { action: 'edit', company_id: companyId },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'designer',
item_data: {
...data,
company_id: companyId,
company_type: 'designer',
images: processedImages as any
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitOperatorCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'operator',
content: { action: 'create' },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'operator',
item_data: {
...data,
company_type: 'operator',
images: processedImages as unknown as Json
},
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitOperatorUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch operator: ${fetchError.message}`);
if (!existingCompany) throw new Error('Operator not found');
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'operator',
content: { action: 'edit', company_id: companyId },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'operator',
item_data: {
...data,
company_id: companyId,
company_type: 'operator',
images: processedImages as any
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitPropertyOwnerCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'property_owner',
content: { action: 'create' },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'property_owner',
item_data: {
...data,
company_type: 'property_owner',
images: processedImages as unknown as Json
},
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
export async function submitPropertyOwnerUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch property owner: ${fetchError.message}`);
if (!existingCompany) throw new Error('Property owner not found');
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error) {
console.error('Failed to upload images:', error);
throw new Error('Failed to upload images. Please check your connection and try again.');
}
}
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'property_owner',
content: { action: 'edit', company_id: companyId },
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'property_owner',
item_data: {
...data,
company_id: companyId,
company_type: 'property_owner',
images: processedImages as any
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}