mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 11:11:12 -05:00
feat: Implement composite submission system
This commit is contained in:
@@ -9,6 +9,26 @@ import { logger } from './logger';
|
||||
import { getErrorMessage } from './errorHandler';
|
||||
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
|
||||
|
||||
// ============================================
|
||||
// COMPOSITE SUBMISSION TYPES
|
||||
// ============================================
|
||||
|
||||
interface CompositeSubmissionDependency {
|
||||
type: 'park' | 'ride' | 'company' | 'ride_model';
|
||||
data: any;
|
||||
tempId: string;
|
||||
companyType?: 'manufacturer' | 'designer' | 'operator' | 'property_owner';
|
||||
parentTempId?: string; // For linking ride_model to manufacturer
|
||||
}
|
||||
|
||||
interface CompositeSubmissionData {
|
||||
primaryEntity: {
|
||||
type: 'park' | 'ride';
|
||||
data: any;
|
||||
};
|
||||
dependencies: CompositeSubmissionDependency[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
|
||||
@@ -154,6 +174,172 @@ export interface RideModelFormData {
|
||||
card_image_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* COMPOSITE SUBMISSION HANDLER
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* Creates a single submission containing multiple related entities
|
||||
* (e.g., Park + Operator, Ride + Manufacturer + Model)
|
||||
*
|
||||
* All entities are submitted atomically through the moderation queue,
|
||||
* with dependency tracking to ensure correct approval order.
|
||||
*
|
||||
* @param primaryEntity - The main entity being created (park or ride)
|
||||
* @param dependencies - Array of dependent entities (companies, models)
|
||||
* @param userId - The ID of the user submitting
|
||||
* @returns Object containing submitted boolean and submissionId
|
||||
*/
|
||||
async function submitCompositeCreation(
|
||||
primaryEntity: { type: 'park' | 'ride'; data: any },
|
||||
dependencies: CompositeSubmissionDependency[],
|
||||
userId: string
|
||||
): Promise<{ submitted: boolean; submissionId: string }> {
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (profile?.banned) {
|
||||
throw new Error('Account suspended. Contact support for assistance.');
|
||||
}
|
||||
|
||||
// Upload all pending images for all entities
|
||||
const uploadedEntities = await Promise.all([
|
||||
...dependencies.map(async (dep) => {
|
||||
if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) {
|
||||
const uploadedImages = await uploadPendingImages(dep.data.images.uploaded);
|
||||
return {
|
||||
...dep,
|
||||
data: {
|
||||
...dep.data,
|
||||
images: { ...dep.data.images, uploaded: uploadedImages }
|
||||
}
|
||||
};
|
||||
}
|
||||
return dep;
|
||||
}),
|
||||
(async () => {
|
||||
if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) {
|
||||
const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded);
|
||||
return {
|
||||
...primaryEntity,
|
||||
data: {
|
||||
...primaryEntity.data,
|
||||
images: { ...primaryEntity.data.images, uploaded: uploadedImages }
|
||||
}
|
||||
};
|
||||
}
|
||||
return primaryEntity;
|
||||
})()
|
||||
]);
|
||||
|
||||
const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[];
|
||||
const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity;
|
||||
|
||||
// Build submission items array with dependencies first
|
||||
const submissionItems: any[] = [];
|
||||
const tempIdMap = new Map<string, number>(); // Maps tempId to order_index
|
||||
|
||||
// Add dependency items (companies, models) first
|
||||
let orderIndex = 0;
|
||||
for (const dep of uploadedDependencies) {
|
||||
const itemType = dep.type === 'company' ? dep.companyType : dep.type;
|
||||
tempIdMap.set(dep.tempId, orderIndex);
|
||||
|
||||
const itemData: any = {
|
||||
...extractChangedFields(dep.data, {}),
|
||||
images: dep.data.images as unknown as Json
|
||||
};
|
||||
|
||||
// Add company_type for company entities
|
||||
if (dep.type === 'company') {
|
||||
itemData.company_type = dep.companyType;
|
||||
}
|
||||
|
||||
// Add manufacturer dependency for ride models
|
||||
if (dep.type === 'ride_model' && dep.parentTempId) {
|
||||
const parentOrderIndex = tempIdMap.get(dep.parentTempId);
|
||||
if (parentOrderIndex !== undefined) {
|
||||
itemData._temp_manufacturer_ref = parentOrderIndex;
|
||||
}
|
||||
}
|
||||
|
||||
submissionItems.push({
|
||||
item_type: itemType,
|
||||
action_type: 'create' as const,
|
||||
item_data: itemData,
|
||||
status: 'pending' as const,
|
||||
order_index: orderIndex,
|
||||
depends_on: null // Dependencies don't have parents
|
||||
});
|
||||
|
||||
orderIndex++;
|
||||
}
|
||||
|
||||
// Add primary entity last
|
||||
const primaryData: any = {
|
||||
...extractChangedFields(uploadedPrimary.data, {}),
|
||||
images: uploadedPrimary.data.images as unknown as Json
|
||||
};
|
||||
|
||||
// Map temporary IDs to order indices for foreign keys
|
||||
if (uploadedPrimary.type === 'park') {
|
||||
if (uploadedPrimary.data.operator_id?.startsWith('temp-')) {
|
||||
const opIndex = tempIdMap.get('temp-operator');
|
||||
if (opIndex !== undefined) primaryData._temp_operator_ref = opIndex;
|
||||
delete primaryData.operator_id;
|
||||
}
|
||||
if (uploadedPrimary.data.property_owner_id?.startsWith('temp-')) {
|
||||
const ownerIndex = tempIdMap.get('temp-property-owner');
|
||||
if (ownerIndex !== undefined) primaryData._temp_property_owner_ref = ownerIndex;
|
||||
delete primaryData.property_owner_id;
|
||||
}
|
||||
} else if (uploadedPrimary.type === 'ride') {
|
||||
if (uploadedPrimary.data.manufacturer_id?.startsWith('temp-')) {
|
||||
const mfgIndex = tempIdMap.get('temp-manufacturer');
|
||||
if (mfgIndex !== undefined) primaryData._temp_manufacturer_ref = mfgIndex;
|
||||
delete primaryData.manufacturer_id;
|
||||
}
|
||||
if (uploadedPrimary.data.ride_model_id?.startsWith('temp-')) {
|
||||
const modelIndex = tempIdMap.get('temp-ride-model');
|
||||
if (modelIndex !== undefined) primaryData._temp_ride_model_ref = modelIndex;
|
||||
delete primaryData.ride_model_id;
|
||||
}
|
||||
}
|
||||
|
||||
submissionItems.push({
|
||||
item_type: uploadedPrimary.type,
|
||||
action_type: 'create' as const,
|
||||
item_data: primaryData,
|
||||
status: 'pending' as const,
|
||||
order_index: orderIndex,
|
||||
depends_on: null // Will be set by RPC based on refs
|
||||
});
|
||||
|
||||
// Use RPC to create submission with items atomically
|
||||
const { data: result, error } = await supabase.rpc('create_submission_with_items', {
|
||||
p_user_id: userId,
|
||||
p_submission_type: uploadedPrimary.type,
|
||||
p_content: { action: 'create' } as unknown as Json,
|
||||
p_items: submissionItems as unknown as Json[]
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error('Composite submission failed', {
|
||||
action: 'composite_submission',
|
||||
primaryType: uploadedPrimary.type,
|
||||
dependencyCount: dependencies.length,
|
||||
error: getErrorMessage(error)
|
||||
});
|
||||
throw new Error(`Failed to create composite submission: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
return { submitted: true, submissionId: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
||||
*
|
||||
@@ -174,9 +360,41 @@ export interface RideModelFormData {
|
||||
* @returns Object containing submitted boolean and submissionId
|
||||
*/
|
||||
export async function submitParkCreation(
|
||||
data: ParkFormData,
|
||||
data: ParkFormData & { _compositeSubmission?: any },
|
||||
userId: string
|
||||
): Promise<{ submitted: boolean; submissionId: string }> {
|
||||
// Check for composite submission with dependencies
|
||||
if (data._compositeSubmission) {
|
||||
const dependencies: CompositeSubmissionDependency[] = [];
|
||||
|
||||
if (data._compositeSubmission.new_operator) {
|
||||
dependencies.push({
|
||||
type: 'company',
|
||||
data: { ...data._compositeSubmission.new_operator, company_type: 'operator' },
|
||||
tempId: 'temp-operator',
|
||||
companyType: 'operator'
|
||||
});
|
||||
}
|
||||
|
||||
if (data._compositeSubmission.new_property_owner) {
|
||||
dependencies.push({
|
||||
type: 'company',
|
||||
data: { ...data._compositeSubmission.new_property_owner, company_type: 'property_owner' },
|
||||
tempId: 'temp-property-owner',
|
||||
companyType: 'property_owner'
|
||||
});
|
||||
}
|
||||
|
||||
if (dependencies.length > 0) {
|
||||
return submitCompositeCreation(
|
||||
{ type: 'park', data: data._compositeSubmission.park },
|
||||
dependencies,
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Standard single-entity creation
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
@@ -368,9 +586,41 @@ export async function submitParkUpdate(
|
||||
* @returns Object containing submitted boolean and submissionId
|
||||
*/
|
||||
export async function submitRideCreation(
|
||||
data: RideFormData,
|
||||
data: RideFormData & { _tempNewManufacturer?: any; _tempNewRideModel?: any },
|
||||
userId: string
|
||||
): Promise<{ submitted: boolean; submissionId: string }> {
|
||||
// Check for composite submission with dependencies
|
||||
if (data._tempNewManufacturer || data._tempNewRideModel) {
|
||||
const dependencies: CompositeSubmissionDependency[] = [];
|
||||
|
||||
if (data._tempNewManufacturer) {
|
||||
dependencies.push({
|
||||
type: 'company',
|
||||
data: { ...data._tempNewManufacturer, company_type: 'manufacturer' },
|
||||
tempId: 'temp-manufacturer',
|
||||
companyType: 'manufacturer'
|
||||
});
|
||||
}
|
||||
|
||||
if (data._tempNewRideModel) {
|
||||
dependencies.push({
|
||||
type: 'ride_model',
|
||||
data: data._tempNewRideModel,
|
||||
tempId: 'temp-ride-model',
|
||||
parentTempId: data._tempNewManufacturer ? 'temp-manufacturer' : undefined
|
||||
});
|
||||
}
|
||||
|
||||
if (dependencies.length > 0) {
|
||||
return submitCompositeCreation(
|
||||
{ type: 'ride', data },
|
||||
dependencies,
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Standard single-entity creation
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
|
||||
Reference in New Issue
Block a user