Files
thrilltrack-explorer/src/lib/entitySubmissionHelpers.ts
gpt-engineer-app[bot] 6f1baef8c0 Approve tool use
2025-10-20 12:58:09 +00:00

1348 lines
40 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';
import type { ProcessedImage } from './supabaseHelpers';
import { extractChangedFields } from './submissionChangeDetection';
import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data';
import { logger } from './logger';
import { getErrorMessage } from './errorHandler';
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
/**
* ═══════════════════════════════════════════════════════════════════
* 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;
}
export interface RideModelFormData {
name: string;
slug: string;
manufacturer_id: string;
category: string;
ride_type?: string;
description?: 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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Park image upload failed', {
action: 'park_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
// 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',
action_type: 'create',
item_data: {
...extractChangedFields(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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Park image upload failed', {
action: 'park_update',
parkId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
// 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',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingPark),
park_id: parkId, // Always include for relational integrity
images: processedImages as unknown as Json
},
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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Ride image upload failed', {
action: 'ride_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
// 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',
action_type: 'create',
item_data: {
...extractChangedFields(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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Ride image upload failed', {
action: 'ride_update',
rideId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
// 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',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingRide),
ride_id: rideId, // Always include for relational integrity
images: processedImages as unknown as Json
},
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 ride model for creation through the moderation queue.
* This is the ONLY correct way to create ride models.
*
* DO NOT use direct database inserts:
* ❌ await supabase.from('ride_models').insert(data) // BYPASSES MODERATION!
* ✅ await submitRideModelCreation(data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* @param data - The ride model form data to submit
* @param userId - The ID of the user submitting the ride model
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideModelCreation(
data: RideModelFormData,
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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Ride model image upload failed', {
action: 'ride_model_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride_model',
content: {
action: 'create'
},
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual ride model data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride_model',
action_type: 'create',
item_data: {
...extractChangedFields(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 a ride model update through the moderation queue.
* This is the ONLY correct way to update ride models.
*/
export async function submitRideModelUpdate(
rideModelId: string,
data: RideModelFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Fetch existing ride model
const { data: existingModel, error: fetchError } = await supabase
.from('ride_models')
.select('*')
.eq('id', rideModelId)
.single();
if (fetchError) throw new Error(`Failed to fetch ride model: ${fetchError.message}`);
if (!existingModel) throw new Error('Ride model 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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Ride model image upload failed', {
action: 'ride_model_update',
rideModelId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride_model',
content: {
action: 'edit',
ride_model_id: rideModelId
},
status: 'pending'
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual ride model data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride_model',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingModel),
ride_model_id: rideModelId, // Always include for relational integrity
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingModel)),
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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'manufacturer_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'create',
item_data: {
...extractChangedFields(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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'manufacturer_update',
companyId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
company_id: companyId, // Always include for relational integrity
company_type: 'manufacturer', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'designer_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'create',
item_data: {
...extractChangedFields(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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'designer_update',
companyId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
company_id: companyId, // Always include for relational integrity
company_type: 'designer', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'operator_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'create',
item_data: {
...extractChangedFields(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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'operator_update',
companyId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
company_id: companyId, // Always include for relational integrity
company_type: 'operator', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'property_owner_creation',
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'create',
item_data: {
...extractChangedFields(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: unknown) {
const errorMessage = getErrorMessage(error);
logger.error('Company image upload failed', {
action: 'property_owner_update',
companyId,
error: errorMessage
});
throw new Error(`Failed to upload images: ${errorMessage}`);
}
}
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',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as Partial<CompanyDatabaseRecord>),
company_id: companyId, // Always include for relational integrity
company_type: 'property_owner', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending',
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new timeline event for an entity through the moderation queue.
*
* DO NOT write directly to entity_timeline_events:
* ❌ await supabase.from('entity_timeline_events').insert(data) // BYPASSES MODERATION!
* ✅ await submitTimelineEvent(entityType, entityId, data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Database Write
*
* @param entityType - Type of entity (park, ride, company)
* @param entityId - ID of the entity
* @param data - The timeline event form data
* @param userId - The ID of the user submitting
* @returns Object containing submitted boolean and submissionId
*/
export async function submitTimelineEvent(
entityType: EntityType,
entityId: string,
data: TimelineEventFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Validate user
if (!userId) {
throw new Error('User ID is required for timeline event submission');
}
// Create submission content (minimal reference data only)
const content: Json = {
action: 'create',
entity_type: entityType,
entity_id: entityId,
};
// Create the main submission record
// Use atomic RPC function to create submission + items in transaction
const itemData: Record<string, any> = {
entity_type: entityType,
entity_id: entityId,
event_type: data.event_type,
event_date: data.event_date.toISOString().split('T')[0],
event_date_precision: data.event_date_precision,
title: data.title,
description: data.description,
from_value: data.from_value,
to_value: data.to_value,
from_entity_id: data.from_entity_id,
to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id,
to_location_id: data.to_location_id,
is_public: true,
};
const items = [{
item_type: 'timeline_event',
action_type: 'create',
item_data: itemData,
order_index: 0,
}];
const { data: submissionId, error } = await supabase
.rpc('create_submission_with_items', {
p_user_id: userId,
p_submission_type: 'timeline_event',
p_content: content,
p_items: items as unknown as Json[],
});
if (error || !submissionId) {
const errorMessage = getErrorMessage(error);
logger.error('Timeline event submission failed', {
action: 'create_timeline_event',
userId,
error: errorMessage
});
throw new Error('Failed to submit timeline event for review');
}
return {
submitted: true,
submissionId: submissionId,
};
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits an update to an existing timeline event through the moderation queue.
*
* @param eventId - ID of the existing timeline event
* @param data - The updated timeline event data
* @param userId - The ID of the user submitting the update
* @returns Object containing submitted boolean and submissionId
*/
export async function submitTimelineEventUpdate(
eventId: string,
data: TimelineEventFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Fetch original event
const { data: originalEvent, error: fetchError } = await supabase
.from('entity_timeline_events')
.select('*')
.eq('id', eventId)
.single();
if (fetchError || !originalEvent) {
throw new Error('Failed to fetch original timeline event');
}
// Extract only changed fields from form data
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
const itemData: Record<string, unknown> = {
...changedFields,
// Always include entity reference (for FK integrity)
entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id,
is_public: true,
};
// Use atomic RPC function to create submission and item together
const { data: result, error: rpcError } = await supabase.rpc(
'create_submission_with_items',
{
p_user_id: userId,
p_submission_type: 'timeline_event',
p_content: {
action: 'edit',
event_id: eventId,
entity_type: originalEvent.entity_type,
} as unknown as Json,
p_items: [
{
item_type: 'timeline_event',
action_type: 'edit',
item_data: itemData,
original_data: originalEvent,
status: 'pending',
order_index: 0,
}
] as unknown as Json[],
}
);
if (rpcError || !result) {
const errorMessage = getErrorMessage(rpcError);
logger.error('Timeline event update failed', {
action: 'update_timeline_event',
eventId,
error: errorMessage
});
throw new Error('Failed to submit timeline event update');
}
return {
submitted: true,
submissionId: result,
};
}
export async function deleteTimelineEvent(
eventId: string,
userId: string
): Promise<void> {
// First verify the event exists and user has permission
const { data: event, error: fetchError } = await supabase
.from('entity_timeline_events')
.select('created_by, approved_by')
.eq('id', eventId)
.single();
if (fetchError) {
const errorMessage = getErrorMessage(fetchError);
logger.error('Timeline event fetch failed', {
action: 'delete_timeline_event',
eventId,
error: errorMessage
});
throw new Error('Timeline event not found');
}
if (!event) {
throw new Error('Timeline event not found');
}
// Only allow deletion of own unapproved events
if (event.created_by !== userId) {
throw new Error('You can only delete your own timeline events');
}
if (event.approved_by !== null) {
throw new Error('Cannot delete approved timeline events');
}
// Delete the event
const { error: deleteError } = await supabase
.from('entity_timeline_events')
.delete()
.eq('id', eventId);
if (deleteError) {
const errorMessage = getErrorMessage(deleteError);
logger.error('Timeline event deletion failed', {
action: 'delete_timeline_event',
eventId,
error: errorMessage
});
throw new Error('Failed to delete timeline event');
}
logger.info('Timeline event deleted', {
action: 'delete_timeline_event',
eventId,
userId
});
}